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 := "" 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{ "隐藏注释": "", "旧 YouTrack 格式": "\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", "") 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, "") { t.Fatalf("正文应带 spec marker: %q", is.Body) } want := "" 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", "\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" { t.Fatalf("正文 marker 不对: %q", is.Body) } want := "" 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", "") srv.AddComment(n, "alice", `{"column":"todo"}`) srv.AddComment(n, "alice", "\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 != "\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 时应报错") } }