400 lines
15 KiB
Go
400 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/cruldra/superpowers-vscode/cli/internal/forge"
|
|
"github.com/cruldra/superpowers-vscode/cli/internal/forge/forgetest"
|
|
"github.com/cruldra/superpowers-vscode/cli/internal/repo"
|
|
)
|
|
|
|
// flavors runs fn once against a fake Gitea and once against a fake GitHub.
|
|
func flavors(t *testing.T, fn func(t *testing.T, srv *forgetest.Server)) {
|
|
for _, c := range []struct {
|
|
name string
|
|
flavor forgetest.Flavor
|
|
}{{"gitea", forgetest.Gitea}, {"github", forgetest.GitHub}} {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
fn(t, forgetest.NewServer(t, c.flavor))
|
|
})
|
|
}
|
|
}
|
|
|
|
// runSpx executes the spx command tree with args against srv and returns what it printed to stdout.
|
|
func runSpx(t *testing.T, srv *forgetest.Server, args ...string) (string, error) {
|
|
t.Helper()
|
|
root := buildRootCmd(func() (forge.Forge, error) {
|
|
if srv.Flavor == forgetest.GitHub {
|
|
return forge.NewGitHub(srv.URL, srv.Token, forgetest.Owner, forgetest.Repo), nil
|
|
}
|
|
return forge.NewGitea(srv.URL, srv.Token, forgetest.Owner, forgetest.Repo), nil
|
|
})
|
|
var out, errOut bytes.Buffer
|
|
root.SetOut(&out)
|
|
root.SetErr(&errOut)
|
|
root.SetArgs(args)
|
|
err := root.Execute()
|
|
return out.String(), err
|
|
}
|
|
|
|
func mustRun(t *testing.T, srv *forgetest.Server, args ...string) string {
|
|
t.Helper()
|
|
out, err := runSpx(t, srv, args...)
|
|
if err != nil {
|
|
t.Fatalf("spx %s 失败: %v", strings.Join(args, " "), err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func decodeJSON(t *testing.T, s string) map[string]any {
|
|
t.Helper()
|
|
var m map[string]any
|
|
if err := json.Unmarshal([]byte(s), &m); err != nil {
|
|
t.Fatalf("输出不是 JSON 对象: %v\n%s", err, s)
|
|
}
|
|
return m
|
|
}
|
|
|
|
func lastComment(t *testing.T, srv *forgetest.Server, n int) string {
|
|
t.Helper()
|
|
cs := srv.Comments(n)
|
|
if len(cs) == 0 {
|
|
t.Fatalf("#%d 没有评论", n)
|
|
}
|
|
return cs[len(cs)-1].Body
|
|
}
|
|
|
|
func TestIssueStateMergeWritesHiddenStateComment(t *testing.T) {
|
|
flavors(t, func(t *testing.T, srv *forgetest.Server) {
|
|
n := srv.AddIssue(forgetest.Issue{Title: "t", Author: "bob"})
|
|
srv.AddComment(n, "bob", `{"column":"todo","branch":"b1"}`)
|
|
srv.AddComment(n, "bob", "LGTM")
|
|
out := mustRun(t, srv, "issue", "state", "merge", "--issue", fmt.Sprint(n), "--state-json", `{"column":"review","handoffRequestError":"a --> b"}`)
|
|
want := "<!-- spx-state\n{\"branch\":\"b1\",\"column\":\"review\",\"handoffRequestError\":\"a --\\u003e b\"}\n-->"
|
|
if got := lastComment(t, srv, n); got != want {
|
|
t.Fatalf("写出的 state 评论不对:\ngot %q\nwant %q", got, want)
|
|
}
|
|
m := decodeJSON(t, out)
|
|
if m["column"] != "review" || m["branch"] != "b1" || m["handoffRequestError"] != "a --> b" {
|
|
t.Fatalf("merge 输出不对: %v", m)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestIssueStateGetReadsEveryFormat(t *testing.T) {
|
|
bodies := map[string]string{
|
|
"隐藏注释": "<!-- spx-state\n{\"column\":\"review\"}\n-->",
|
|
"旧 YouTrack 格式": "<!--spx-state-->\n{\"column\":\"review\"}",
|
|
"旧裸 JSON": `{"column":"review"}`,
|
|
}
|
|
for name, body := range bodies {
|
|
t.Run(name, func(t *testing.T) {
|
|
flavors(t, func(t *testing.T, srv *forgetest.Server) {
|
|
n := srv.AddIssue(forgetest.Issue{Title: "t", Author: "bob"})
|
|
srv.AddComment(n, "bob", `{"column":"todo"}`)
|
|
srv.AddComment(n, "bob", body)
|
|
srv.AddComment(n, "bob", "普通评论")
|
|
out := mustRun(t, srv, "issue", "state", "get", "--issue", fmt.Sprint(n))
|
|
if strings.TrimSpace(out) != `{"column":"review"}` {
|
|
t.Fatalf("state get 输出不对: %q", out)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIssueStateGetFindsStateOnLaterGitHubPage(t *testing.T) {
|
|
srv := forgetest.NewServer(t, forgetest.GitHub)
|
|
n := srv.AddIssue(forgetest.Issue{Title: "t", Author: "bob"})
|
|
srv.AddComment(n, "bob", `{"column":"todo"}`)
|
|
for i := 0; i < 220; i++ {
|
|
srv.AddComment(n, "bob", fmt.Sprintf("聊天 %d", i))
|
|
}
|
|
srv.AddComment(n, "bob", "<!-- spx-state\n{\"column\":\"done\"}\n-->")
|
|
srv.AddComment(n, "bob", "收尾")
|
|
out := mustRun(t, srv, "issue", "state", "get", "--issue", fmt.Sprint(n))
|
|
if strings.TrimSpace(out) != `{"column":"done"}` {
|
|
t.Fatalf("应读到第 3 页上的最新 state,得到 %q", out)
|
|
}
|
|
}
|
|
|
|
func TestIssueStateGetEmptyIssuePrintsEmptyObject(t *testing.T) {
|
|
srv := forgetest.NewServer(t, forgetest.GitHub)
|
|
n := srv.AddIssue(forgetest.Issue{Title: "t", Author: "bob"})
|
|
out := mustRun(t, srv, "issue", "state", "get", "--issue", fmt.Sprint(n))
|
|
if strings.TrimSpace(out) != `{}` {
|
|
t.Fatalf("没有 state 时应输出 {},得到 %q", out)
|
|
}
|
|
}
|
|
|
|
func TestIssueCreateAssignsSelfAndWritesHiddenState(t *testing.T) {
|
|
flavors(t, func(t *testing.T, srv *forgetest.Server) {
|
|
out := mustRun(t, srv, "issue", "create", "--title", "新功能", "--body", "正文",
|
|
"--spec", "docs/superpowers/specs/x.md", "--state-json", `{"column":"todo"}`, "--json")
|
|
m := decodeJSON(t, out)
|
|
n := int(m["number"].(float64))
|
|
is, ok := srv.Issue(n)
|
|
if !ok || is.Title != "新功能" || !reflect.DeepEqual(is.Assignees, []string{"alice"}) {
|
|
t.Fatalf("建出的工单不对: %+v", is)
|
|
}
|
|
if !strings.Contains(is.Body, "<!-- spx:spec=docs/superpowers/specs/x.md -->") {
|
|
t.Fatalf("正文应带 spec marker: %q", is.Body)
|
|
}
|
|
want := "<!-- spx-state\n{\"column\":\"todo\",\"specFile\":\"docs/superpowers/specs/x.md\"}\n-->"
|
|
if got := lastComment(t, srv, n); got != want {
|
|
t.Fatalf("state 评论不对:\ngot %q\nwant %q", got, want)
|
|
}
|
|
if m["html_url"] == "" || m["state"] == nil {
|
|
t.Fatalf("--json 输出缺字段: %v", m)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestIssueMarkerUpdatesBodyAndMergesState(t *testing.T) {
|
|
flavors(t, func(t *testing.T, srv *forgetest.Server) {
|
|
n := srv.AddIssue(forgetest.Issue{Title: "t", Body: "正文", Author: "bob"})
|
|
srv.AddComment(n, "bob", "<!--spx-state-->\n{\"column\":\"in-progress\"}")
|
|
mustRun(t, srv, "issue", "marker", "--issue", fmt.Sprint(n), "--type", "plan", "--value", "docs/superpowers/plans/p.md")
|
|
is, _ := srv.Issue(n)
|
|
if is.Body != "正文\n<!-- spx:plan=docs/superpowers/plans/p.md -->" {
|
|
t.Fatalf("正文 marker 不对: %q", is.Body)
|
|
}
|
|
want := "<!-- spx-state\n{\"column\":\"in-progress\",\"planFile\":\"docs/superpowers/plans/p.md\"}\n-->"
|
|
if got := lastComment(t, srv, n); got != want {
|
|
t.Fatalf("state 评论不对:\ngot %q\nwant %q", got, want)
|
|
}
|
|
})
|
|
}
|
|
|
|
func seedViewIssue(srv *forgetest.Server) int {
|
|
n := srv.AddIssue(forgetest.Issue{Title: "登录失败", Body: "复现步骤", Author: "bob", Assignees: []string{"alice", "carol"}})
|
|
srv.AddComment(n, "bob", "第一条")
|
|
srv.AddComment(n, "alice", "<!-- spx-state\n{\"column\":\"review\"}\n-->")
|
|
srv.AddComment(n, "alice", `{"column":"todo"}`)
|
|
srv.AddComment(n, "alice", "<!--spx-state-->\n{\"column\":\"done\"}")
|
|
srv.AddComment(n, "carol", "第二条")
|
|
return n
|
|
}
|
|
|
|
func TestIssueViewJSONExcludesStateComments(t *testing.T) {
|
|
flavors(t, func(t *testing.T, srv *forgetest.Server) {
|
|
n := seedViewIssue(srv)
|
|
m := decodeJSON(t, mustRun(t, srv, "issue", "view", "--issue", fmt.Sprint(n), "--comments", "--json"))
|
|
if m["number"] != float64(n) || m["title"] != "登录失败" || m["body"] != "复现步骤" || m["state"] != "open" || m["author"] != "bob" {
|
|
t.Fatalf("工单字段不对: %v", m)
|
|
}
|
|
if !reflect.DeepEqual(m["assignees"], []any{"alice", "carol"}) {
|
|
t.Fatalf("assignees 不对: %v", m["assignees"])
|
|
}
|
|
if !strings.HasSuffix(fmt.Sprint(m["html_url"]), fmt.Sprintf("/issues/%d", n)) {
|
|
t.Fatalf("html_url 不对: %v", m["html_url"])
|
|
}
|
|
comments, _ := m["comments"].([]any)
|
|
var got []string
|
|
for _, c := range comments {
|
|
cm := c.(map[string]any)
|
|
got = append(got, fmt.Sprint(cm["author"], ":", cm["body"]))
|
|
if cm["created_at"] == "" || cm["html_url"] == "" {
|
|
t.Fatalf("评论缺字段: %v", cm)
|
|
}
|
|
}
|
|
if !reflect.DeepEqual(got, []string{"bob:第一条", "carol:第二条"}) {
|
|
t.Fatalf("应排除 3 条 state 评论,得到 %v", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestIssueViewWithoutCommentsFlagOmitsComments(t *testing.T) {
|
|
srv := forgetest.NewServer(t, forgetest.Gitea)
|
|
n := seedViewIssue(srv)
|
|
m := decodeJSON(t, mustRun(t, srv, "issue", "view", "--issue", fmt.Sprint(n), "--json"))
|
|
if _, ok := m["comments"]; ok {
|
|
t.Fatalf("不带 --comments 时不应输出 comments: %v", m)
|
|
}
|
|
for _, r := range srv.Requests() {
|
|
if strings.Contains(r, "/comments") {
|
|
t.Fatalf("不带 --comments 时不应拉评论: %s", r)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIssueViewHumanOutput(t *testing.T) {
|
|
srv := forgetest.NewServer(t, forgetest.GitHub)
|
|
n := seedViewIssue(srv)
|
|
out := mustRun(t, srv, "issue", "view", "--issue", fmt.Sprint(n), "--comments")
|
|
for _, want := range []string{fmt.Sprintf("#%d 登录失败", n), "open", "bob", "alice, carol", "复现步骤", "第一条", "第二条"} {
|
|
if !strings.Contains(out, want) {
|
|
t.Fatalf("输出应包含 %q:\n%s", want, out)
|
|
}
|
|
}
|
|
if strings.Contains(out, "spx-state") || strings.Contains(out, `"column"`) {
|
|
t.Fatalf("输出不应包含 state 评论:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestPRViewJSON(t *testing.T) {
|
|
flavors(t, func(t *testing.T, srv *forgetest.Server) {
|
|
n := srv.AddPull(forgetest.Pull{
|
|
Title: "加功能", Body: "说明", State: "closed", Merged: true, Author: "bob",
|
|
HeadBranch: "feat/x", HeadSHA: "aaa111", BaseBranch: "main", BaseSHA: "bbb222",
|
|
})
|
|
m := decodeJSON(t, mustRun(t, srv, "pr", "view", "--pr", fmt.Sprint(n), "--json"))
|
|
want := map[string]any{
|
|
"number": float64(n), "title": "加功能", "body": "说明", "state": "closed", "merged": true, "author": "bob",
|
|
"head": map[string]any{"branch": "feat/x", "sha": "aaa111"},
|
|
"base": map[string]any{"branch": "main", "sha": "bbb222"},
|
|
}
|
|
for k, v := range want {
|
|
if !reflect.DeepEqual(m[k], v) {
|
|
t.Fatalf("pr view 字段 %s = %v, want %v", k, m[k], v)
|
|
}
|
|
}
|
|
if m["html_url"] == "" || m["html_url"] == nil {
|
|
t.Fatalf("pr view 缺 html_url: %v", m)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestPRViewHumanOutput(t *testing.T) {
|
|
srv := forgetest.NewServer(t, forgetest.Gitea)
|
|
n := srv.AddPull(forgetest.Pull{Title: "加功能", Body: "说明", Author: "bob", HeadBranch: "feat/x", HeadSHA: "aaa111", BaseBranch: "main", BaseSHA: "bbb222"})
|
|
out := mustRun(t, srv, "pr", "view", "--pr", fmt.Sprint(n))
|
|
for _, want := range []string{fmt.Sprintf("#%d 加功能", n), "feat/x", "aaa111", "main", "bbb222", "未合并", "说明"} {
|
|
if !strings.Contains(out, want) {
|
|
t.Fatalf("输出应包含 %q:\n%s", want, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPRCreateDefaultsBaseToRepoDefaultBranch(t *testing.T) {
|
|
flavors(t, func(t *testing.T, srv *forgetest.Server) {
|
|
srv.DefaultBranch = "trunk"
|
|
m := decodeJSON(t, mustRun(t, srv, "pr", "create", "--title", "T", "--body", "B", "--head", "feat/y", "--json"))
|
|
n := int(m["number"].(float64))
|
|
p, ok := srv.Pull(n)
|
|
if !ok || p.Title != "T" || p.Body != "B" || p.HeadBranch != "feat/y" || p.BaseBranch != "trunk" {
|
|
t.Fatalf("建出的 PR 不对: %+v", p)
|
|
}
|
|
if !strings.HasSuffix(fmt.Sprint(m["html_url"]), fmt.Sprint(n)) {
|
|
t.Fatalf("输出应带 PR 链接: %v", m)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestPRCreateHonorsBaseAndBodyFile(t *testing.T) {
|
|
srv := forgetest.NewServer(t, forgetest.GitHub)
|
|
bodyFile := filepath.Join(t.TempDir(), "body.md")
|
|
if err := os.WriteFile(bodyFile, []byte("文件正文"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out := mustRun(t, srv, "pr", "create", "--title", "T", "--body-file", bodyFile, "--head", "feat/z", "--base", "release")
|
|
p, ok := srv.Pull(1)
|
|
if !ok || p.Body != "文件正文" || p.BaseBranch != "release" {
|
|
t.Fatalf("建出的 PR 不对: %+v", p)
|
|
}
|
|
if strings.TrimSpace(out) != fmt.Sprintf("#1 %s/acme/widgets/pull/1", srv.URL) {
|
|
t.Fatalf("人类可读输出应是编号加链接,得到 %q", out)
|
|
}
|
|
if _, err := os.Stat(bodyFile); err != nil {
|
|
t.Fatalf("pr create 不应删除 --body-file: %v", err)
|
|
}
|
|
for _, r := range srv.Requests() {
|
|
if strings.HasPrefix(r, "GET /repos/acme/widgets ") || r == "GET /repos/acme/widgets" {
|
|
t.Fatalf("显式 --base 时不应再查默认分支: %v", srv.Requests())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPRCreateRequiresBody(t *testing.T) {
|
|
srv := forgetest.NewServer(t, forgetest.GitHub)
|
|
if _, err := runSpx(t, srv, "pr", "create", "--title", "T", "--head", "feat/z"); err == nil {
|
|
t.Fatal("没有 --body/--body-file 时应报错")
|
|
}
|
|
if len(srv.Requests()) != 0 {
|
|
t.Fatalf("参数不全时不应发请求: %v", srv.Requests())
|
|
}
|
|
}
|
|
|
|
func TestPRReviewCommentUsesIssueCommentsEndpoint(t *testing.T) {
|
|
flavors(t, func(t *testing.T, srv *forgetest.Server) {
|
|
n := srv.AddPull(forgetest.Pull{Title: "t", Author: "bob", HeadBranch: "h", BaseBranch: "main"})
|
|
mustRun(t, srv, "pr", "review-comment", "--pr", fmt.Sprint(n), "--body", "审查意见")
|
|
if got := lastComment(t, srv, n); got != "<!-- spx:review=1 -->\n\n审查意见" {
|
|
t.Fatalf("评论正文不对: %q", got)
|
|
}
|
|
prefix := ""
|
|
if srv.Flavor == forgetest.Gitea {
|
|
prefix = "/api/v1"
|
|
}
|
|
reqs := srv.Requests()
|
|
want := fmt.Sprintf("POST %s/repos/acme/widgets/issues/%d/comments", prefix, n)
|
|
if reqs[len(reqs)-1] != want {
|
|
t.Fatalf("应走 issues comments 接口,实际 %s", reqs[len(reqs)-1])
|
|
}
|
|
})
|
|
}
|
|
|
|
func gitRepoWithOrigin(t *testing.T, remote string) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
for _, args := range [][]string{{"init", "-q"}, {"remote", "add", "origin", remote}} {
|
|
if out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).CombinedOutput(); err != nil {
|
|
t.Fatalf("git %v 失败: %v: %s", args, err, out)
|
|
}
|
|
}
|
|
return dir
|
|
}
|
|
|
|
func TestResolveOriginFromGitRemoteAndOverrides(t *testing.T) {
|
|
dir := gitRepoWithOrigin(t, "git@github.com:acme/widgets.git")
|
|
cases := []struct {
|
|
name, host, repoFlag string
|
|
want repo.Origin
|
|
}{
|
|
{"只看 origin", "", "", repo.Origin{Host: "github.com", WebURL: "https://github.com", Owner: "acme", Repo: "widgets"}},
|
|
{"覆盖 host", "https://gitea.example.com", "", repo.Origin{Host: "gitea.example.com", WebURL: "https://gitea.example.com", Owner: "acme", Repo: "widgets"}},
|
|
{"覆盖 repo", "", "other/thing", repo.Origin{Host: "github.com", WebURL: "https://github.com", Owner: "other", Repo: "thing"}},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
flagCwd, flagHost, flagRepo = dir, c.host, c.repoFlag
|
|
got, err := resolveOrigin()
|
|
if err != nil {
|
|
t.Fatalf("resolveOrigin 出错: %v", err)
|
|
}
|
|
if got != c.want {
|
|
t.Fatalf("got %+v want %+v", got, c.want)
|
|
}
|
|
if forge.KindForHost(got.Host) == forge.KindGitHub != (c.want.Host == "github.com") {
|
|
t.Fatalf("forge 选择不对: host=%s", got.Host)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolveOriginWithHostAndRepoSkipsGit(t *testing.T) {
|
|
flagCwd, flagHost, flagRepo = t.TempDir(), "github.com", "acme/widgets"
|
|
got, err := resolveOrigin()
|
|
if err != nil {
|
|
t.Fatalf("同时给 --host 和 --repo 时不应依赖 git: %v", err)
|
|
}
|
|
if got.Host != "github.com" || got.Owner != "acme" || got.Repo != "widgets" {
|
|
t.Fatalf("got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestResolveOriginRejectsBadRepoFlag(t *testing.T) {
|
|
flagCwd, flagHost, flagRepo = t.TempDir(), "github.com", "no-slash"
|
|
if _, err := resolveOrigin(); err == nil {
|
|
t.Fatal("--repo 不是 OWNER/REPO 时应报错")
|
|
}
|
|
}
|