package thread import ( "context" "fmt" "os" "os/exec" "strconv" "strings" "sync" "testing" "time" "github.com/barerepo/server/internal/gitx" ) func repoWithCommit(t *testing.T) (dir, sha string) { t.Helper() if _, err := gitx.Version(context.Background()); err != nil { t.Skip("git is not installed") } dir = t.TempDir() run := func(args ...string) string { t.Helper() cmd := exec.Command(gitx.Bin, args...) cmd.Dir = dir cmd.Env = append(cmd.Environ(), "GIT_AUTHOR_NAME=lisa", "GIT_AUTHOR_EMAIL=m@x", "GIT_COMMITTER_NAME=lisa", "GIT_COMMITTER_EMAIL=m@x") out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("git %v: %v\n%s", args, err, out) } return strings.TrimSpace(string(out)) } run("init", "-q", "-b", "master") run("commit", "-q", "--allow-empty", "-m", "first") return dir, run("rev-parse", "HEAD") } func TestThreadRoundTrip(t *testing.T) { ctx := context.Background() dir, sha := repoWithCommit(t) m := Meta{ Title: "panic when config file is empty", State: Open, Ref: "refs/proposals/47", Author: "lisa", Opened: time.Unix(1787074650, 0), } first := Comment{Author: "lisa", Time: time.Unix(1787074650, 0), Body: "fresh install, empty config.toml, immediate nil deref on line 44."} if err := Create(ctx, dir, 47, m, sha, first); err != nil { t.Fatal(err) } if err := Create(ctx, dir, 47, m, sha, first); err == nil { t.Error("a thread was opened twice with the same number") } got, exists, err := ReadMeta(ctx, dir, 47) if err != nil || !exists { t.Fatalf("ReadMeta: %v %v", exists, err) } if got.Title != m.Title || got.State != Open || got.Ref != m.Ref || got.Author != "lisa" { t.Errorf("meta round trip lost something: %+v", got) } if err := Reply(ctx, dir, 47, sha, Comment{ Author: "john", Time: time.Unix(1787078250, 0), Anchor: "config.go:43", Body: "Default() allocates every call. make it a package var?", }); err != nil { t.Fatal(err) } // The whole point: git itself can read this, with no barerepo involved. out, err := gitx.Run(ctx, dir, "log", "--show-notes=threads/47", "--max-count=1") if err != nil { t.Fatal(err) } for _, want := range []string{"Notes (threads/47)", "immediate nil deref", "package var?"} { if !strings.Contains(out, want) { t.Errorf("git log --show-notes did not show %q\n%s", want, out) } } // And so can git notes show, by the object's hash. note, err := gitx.Run(ctx, dir, "notes", "--ref=threads/47", "show", sha) if err != nil { t.Fatalf("git notes show: %v", err) } comments := ParseComments(note) if len(comments) != 2 { t.Fatalf("got %d comments, want 2: %+v", len(comments), comments) } if comments[0].Author != "lisa" || comments[1].Author != "john" { t.Errorf("comments are out of order: %+v", comments) } if comments[1].Anchor != "config.go:43" { t.Errorf("the anchor was lost: %+v", comments[1]) } } // Chapter 45.3's thousand, at full count outside short mode, since each write costs processes. func TestConcurrentRepliesAtScale(t *testing.T) { if testing.Short() { t.Skip("chapter 45.3's full count takes minutes") } ctx := context.Background() dir, sha := repoWithCommit(t) if err := Create(ctx, dir, 1, Meta{Title: "scale", State: Open}, "", Comment{}); err != nil { t.Fatal(err) } const rounds, writers = 50, 20 for round := range rounds { var wg sync.WaitGroup errs := make([]error, writers) start := make(chan struct{}) for i := range writers { wg.Add(1) go func(i int) { defer wg.Done() <-start errs[i] = Reply(ctx, dir, 1, sha, Comment{ Author: "writer", Time: time.Unix(int64(1787074650+round*writers+i), 0), Body: fmt.Sprintf("round %d writer %d", round, i), }) }(i) } close(start) wg.Wait() for i, err := range errs { if err != nil { t.Fatalf("round %d writer %d: %v", round, i, err) } } } note, err := gitx.Run(ctx, dir, "notes", "--ref=threads/1", "show", sha) if err != nil { t.Fatal(err) } if got := len(ParseComments(note)); got != rounds*writers { t.Errorf("kept %d comments of %d: %d were dropped", got, rounds*writers, rounds*writers-got) } } // Chapter 45.3: two comments at one moment, both surviving, because the loser reads again. func TestConcurrentReplies(t *testing.T) { ctx := context.Background() dir, sha := repoWithCommit(t) if err := Create(ctx, dir, 1, Meta{Title: "concurrency", State: Open}, "", Comment{}); err != nil { t.Fatal(err) } const n = 12 var wg sync.WaitGroup errs := make([]error, n) start := make(chan struct{}) for i := 0; i < n; i++ { wg.Add(1) go func(i int) { defer wg.Done() <-start errs[i] = Reply(ctx, dir, 1, sha, Comment{ Author: "writer", Time: time.Unix(int64(1787074650+i), 0), Body: "comment " + string(rune('a'+i)), }) }(i) } close(start) wg.Wait() for i, err := range errs { if err != nil { t.Fatalf("reply %d failed: %v", i, err) } } note, err := gitx.Run(ctx, dir, "notes", "--ref=threads/1", "show", sha) if err != nil { t.Fatal(err) } if got := len(ParseComments(note)); got != n { t.Errorf("kept %d comments of %d; the rest were dropped", got, n) } } func TestSetState(t *testing.T) { ctx := context.Background() dir, _ := repoWithCommit(t) if err := Create(ctx, dir, 5, Meta{Title: "t", State: Open}, "", Comment{}); err != nil { t.Fatal(err) } if err := SetState(ctx, dir, 5, Merged, "a3f9c2d"); err != nil { t.Fatal(err) } m, _, _ := ReadMeta(ctx, dir, 5) if m.State != Merged || m.Merged != "a3f9c2d" { t.Errorf("state = %+v", m) } if m.Title != "t" { t.Error("changing the state lost the title") } } func TestParseCommentsIgnoresJunk(t *testing.T) { if got := ParseComments("no headers here"); len(got) != 0 { t.Errorf("parsed %d comments from junk", len(got)) } if got := ParseComments(""); len(got) != 0 { t.Errorf("parsed %d comments from nothing", len(got)) } } // helperEnv carries one reply into the child process started below. const helperEnv = "BAREREPO_THREAD_HELPER" // TestReplyHelper is a child process: it writes one comment and exits. func TestReplyHelper(t *testing.T) { spec := os.Getenv(helperEnv) if spec == "" { t.Skip("not a helper process") } fields := strings.Split(spec, "\x1e") if len(fields) != 3 { t.Fatalf("helper got %q", spec) } when, _ := strconv.ParseInt(fields[2], 10, 64) err := Reply(context.Background(), fields[0], 1, fields[1], Comment{ Author: "writer", Time: time.Unix(when, 0), Body: fmt.Sprintf("from process %d", when), }) if err != nil { t.Fatalf("helper reply: %v", err) } } // Chapter 45.3 across processes, which is a hook and the web server writing at once, and all the swap saves. func TestConcurrentRepliesAcrossProcesses(t *testing.T) { ctx := context.Background() dir, sha := repoWithCommit(t) if err := Create(ctx, dir, 1, Meta{Title: "processes", State: Open}, "", Comment{}); err != nil { t.Fatal(err) } const n = 8 var wg sync.WaitGroup errs := make([]error, n) start := make(chan struct{}) for i := range n { wg.Add(1) go func(i int) { defer wg.Done() cmd := exec.Command(os.Args[0], "-test.run=TestReplyHelper", "-test.v") cmd.Env = append(os.Environ(), fmt.Sprintf("%s=%s\x1e%s\x1e%d", helperEnv, dir, sha, 1787074650+i)) <-start if out, err := cmd.CombinedOutput(); err != nil { errs[i] = fmt.Errorf("%v\n%s", err, out) } }(i) } close(start) wg.Wait() for i, err := range errs { if err != nil { t.Fatalf("process %d: %v", i, err) } } note, err := gitx.Run(ctx, dir, "notes", "--ref=threads/1", "show", sha) if err != nil { t.Fatal(err) } if got := len(ParseComments(note)); got != n { t.Errorf("kept %d comments of %d; a second writer's comment was dropped", got, n) } }