Files
superwork/tui/internal/tui/webhook_test.go
T
2026-06-23 05:02:15 +08:00

640 lines
20 KiB
Go

package tui
import (
"context"
"fmt"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"superwork-tui/internal/cc"
"superwork-tui/internal/config"
"superwork-tui/internal/gitea"
"superwork-tui/internal/issue"
"superwork-tui/internal/webhook"
)
// ── waitForWebhookCmd ────────────────────────────────────────────────────────
func TestWaitForWebhookCmd_DeliversEvent(t *testing.T) {
ch := make(chan webhook.Event, 1)
ch <- webhook.PushEvent{Branch: "main"}
ctx := context.Background()
cmd := waitForWebhookCmd(ctx, ch)
msg := cmd()
got, ok := msg.(webhookEventMsg)
if !ok {
t.Fatalf("want webhookEventMsg, got %T", msg)
}
if _, ok := got.ev.(webhook.PushEvent); !ok {
t.Fatalf("want PushEvent, got %T", got.ev)
}
}
// Fix 1: goroutine leak — cancelling ctx must cause waitForWebhookCmd to
// return nil (not block forever), and the dispatch loop must NOT re-issue.
func TestWaitForWebhookCmd_CancelReturnsNil(t *testing.T) {
ch := make(chan webhook.Event) // unbuffered — no event will arrive
ctx, cancel := context.WithCancel(context.Background())
cancel() // already cancelled
cmd := waitForWebhookCmd(ctx, ch)
msg := cmd()
if msg != nil {
t.Fatalf("want nil msg on cancel, got %T", msg)
}
}
func TestDispatch_WebhookEventMsg_NilEvDoesNotReissue(t *testing.T) {
m := Model{
webhookCtx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}(),
webhookCh: make(chan webhook.Event),
}
// A nil ev (produced by the cancel path) must return no cmd (loop terminated).
newModel, cmd := m.Update(webhookEventMsg{ev: nil})
_ = newModel
if cmd != nil {
t.Error("expected nil cmd when ev is nil (cancel path), got non-nil")
}
}
// ── PushEvent dispatch ────────────────────────────────────────────────────────
func TestDispatch_PushEvent_DevBranch_TriggersBranchSync(t *testing.T) {
origLoad := configLoadFn
defer func() { configLoadFn = origLoad }()
configLoadFn = func() (*config.Settings, error) {
return &config.Settings{DevBranch: "main", WebhookPort: 17421}, nil
}
syncCalled := false
origSync := branchSyncCmdFn
defer func() { branchSyncCmdFn = origSync }()
branchSyncCmdFn = func() tea.Cmd {
syncCalled = true
return nil
}
m := Model{webhookCh: make(chan webhook.Event, 1)}
var cmds []tea.Cmd
m, _ = handlePushEvent(m, webhook.PushEvent{Branch: "main"}, cmds)
_ = m
if !syncCalled {
t.Error("expected branchSyncCmdFn to be called for dev branch push")
}
}
func TestDispatch_PushEvent_UnrelatedBranch_NoSync(t *testing.T) {
origLoad := configLoadFn
defer func() { configLoadFn = origLoad }()
configLoadFn = func() (*config.Settings, error) {
return &config.Settings{DevBranch: "main", WebhookPort: 17421}, nil
}
syncCalled := false
origSync := branchSyncCmdFn
defer func() { branchSyncCmdFn = origSync }()
branchSyncCmdFn = func() tea.Cmd {
syncCalled = true
return nil
}
m := Model{webhookCh: make(chan webhook.Event, 1)}
var cmds []tea.Cmd
m, _ = handlePushEvent(m, webhook.PushEvent{Branch: "feature/unrelated"}, cmds)
_ = m
if syncCalled {
t.Error("branchSyncCmdFn should not be called for unrelated branch")
}
}
// ── IssueCommentEvent dispatch ────────────────────────────────────────────────
func TestDispatch_IssueCommentEvent_NonMarker_NoOp(t *testing.T) {
m := Model{
webhookCh: make(chan webhook.Event, 1),
issues: []issue.Issue{
{Number: 5, PR: "10"},
},
}
var cmds []tea.Cmd
m, resultCmds := handleIssueCommentEvent(m, webhook.IssueCommentEvent{
IssueNumber: 5,
CommentBody: "just a regular comment",
}, cmds)
_ = m
if len(resultCmds) != 0 {
t.Errorf("expected no cmds for non-marker comment, got %d", len(resultCmds))
}
}
func TestDispatch_IssueCommentEvent_MarkerOnlyBody_NoOp(t *testing.T) {
m := Model{
webhookCh: make(chan webhook.Event, 1),
issues: []issue.Issue{{Number: 5}},
sessionMgr: NewSessionManager(func(args ...string) ([]byte, error) {
return nil, nil
}),
}
var cmds []tea.Cmd
m, resultCmds := handleIssueCommentEvent(m, webhook.IssueCommentEvent{
IssueNumber: 5,
CommentBody: "<!-- spx:review=1 -->",
}, cmds)
_ = m
if len(resultCmds) != 0 {
t.Errorf("expected no cmds for marker-only body, got %d", len(resultCmds))
}
}
func TestDispatch_IssueCommentEvent_Marker_InjectsIntoImplTab(t *testing.T) {
// windowExists for the impl window must return true, so InjectFeedback
// injects via send-keys. resolveSessionName() default session is "superwork";
// windowName(5, PhaseImpl) is "5-impl".
rec := &recorder{
responses: map[string][]byte{
"has-session": {},
"list-windows": []byte("5-impl\n"),
},
}
m := Model{
webhookCh: make(chan webhook.Event, 1),
issues: []issue.Issue{{Number: 5, PR: "10"}},
sessionMgr: NewSessionManager(rec.run),
}
var cmds []tea.Cmd
m, _ = handleIssueCommentEvent(m, webhook.IssueCommentEvent{
IssueNumber: 5,
PRNumber: 10,
CommentBody: "<!-- spx:review=1 -->\nplease fix the null check",
}, cmds)
var sentText string
var hasSendKeys bool
for _, call := range rec.calls {
if len(call) > 0 && call[0] == "send-keys" {
hasSendKeys = true
for _, a := range call {
if strings.Contains(a, "please fix the null check") {
sentText = a
}
}
}
}
if !hasSendKeys {
t.Fatal("expected send-keys call (InjectFeedback should inject into impl tab)")
}
if sentText == "" {
t.Error("expected the stripped marker text to be injected")
}
if strings.Contains(sentText, "spx:review") {
t.Error("review marker should have been stripped before injection")
}
if !strings.Contains(m.statusMsg, "已注入") {
t.Errorf("expected success statusMsg, got %q", m.statusMsg)
}
}
func TestDispatch_IssueCommentEvent_IssueNotFound(t *testing.T) {
m := Model{
webhookCh: make(chan webhook.Event, 1),
issues: []issue.Issue{},
sessionMgr: NewSessionManager(func(args ...string) ([]byte, error) {
return nil, nil
}),
}
var cmds []tea.Cmd
m, _ = handleIssueCommentEvent(m, webhook.IssueCommentEvent{
IssueNumber: 99,
CommentBody: "<!-- spx:review=1 -->\nsome feedback",
}, cmds)
if m.statusMsg == "" {
t.Error("expected statusMsg to be set when issue not found")
}
}
// ── IssueEvent dispatch ───────────────────────────────────────────────────────
func TestDispatch_IssueEvent_Opened_QueuesLoad(t *testing.T) {
m := Model{webhookCh: make(chan webhook.Event, 1)}
var cmds []tea.Cmd
m, resultCmds := handleIssueEvent(m, webhook.IssueEvent{
Action: "opened",
IssueNumber: 7,
Body: "some body",
}, cmds)
if m.statusMsg == "" {
t.Error("expected statusMsg after issue opened event")
}
if len(resultCmds) == 0 {
t.Error("expected at least one cmd (loadCmd) after issue opened event")
}
}
func TestDispatch_IssueEvent_Edited_QueuesSync(t *testing.T) {
// "edited" now queues a syncIssueSpecPlanCmd (even with no markers — the cmd
// itself is a no-op when no markers are found). Verify a cmd is queued and
// the statusMsg is updated.
m := Model{webhookCh: make(chan webhook.Event, 1)}
var cmds []tea.Cmd
m, resultCmds := handleIssueEvent(m, webhook.IssueEvent{
Action: "edited",
IssueNumber: 7,
Body: "",
}, cmds)
if len(resultCmds) != 1 {
t.Errorf("edited action should queue syncIssueSpecPlanCmd, got %d cmds", len(resultCmds))
}
if m.statusMsg == "" {
t.Error("expected statusMsg to be set for edited action")
}
}
// ── autoReviewCapturedMsg handling ───────────────────────────────────────────
func TestUpdate_AutoReviewCapturedMsg_UpdatesIssue(t *testing.T) {
m := Model{
webhookCh: make(chan webhook.Event, 1),
issues: []issue.Issue{{Number: 3, Branch: "feat/3-thing"}},
}
msg := autoReviewCapturedMsg{issueNumber: 3, sessionID: "abc123"}
newModel, cmd := m.Update(msg)
nm := newModel.(Model)
found := false
for _, iss := range nm.issues {
if iss.Number == 3 && iss.ReviewSessionID == "abc123" {
found = true
break
}
}
if !found {
t.Error("expected ReviewSessionID to be set on issue #3")
}
if nm.statusMsg == "" {
t.Error("expected statusMsg to be set")
}
if cmd == nil {
t.Error("expected non-nil persist cmd")
}
}
func TestUpdate_AutoReviewCapturedMsg_EmptySession_NoOp(t *testing.T) {
m := Model{
webhookCh: make(chan webhook.Event, 1),
issues: []issue.Issue{{Number: 3}},
}
msg := autoReviewCapturedMsg{issueNumber: 3, sessionID: ""}
newModel, cmd := m.Update(msg)
nm := newModel.(Model)
for _, iss := range nm.issues {
if iss.Number == 3 && iss.ReviewSessionID != "" {
t.Error("expected ReviewSessionID to remain empty")
}
}
if cmd != nil {
t.Error("expected nil cmd when sessionID is empty")
}
}
// ── auto-review capture chain ────────────────────────────────────────────────
// triggerAutoReviewCmd must capture the reviewSessionId via the injected codex
// session watcher seam (which fires early), surfacing it as autoReviewCapturedMsg.
func TestTriggerAutoReviewCmd_CapturesSessionIDViaWatcherSeam(t *testing.T) {
origWatch := webhookWatchCodexSessionFn
defer func() { webhookWatchCodexSessionFn = origWatch }()
var gotSessionsDir string
webhookWatchCodexSessionFn = func(_ context.Context, opts cc.CodexSessionWatchOpts) (string, error) {
gotSessionsDir = opts.SessionsDir
return "rollout-session-xyz", nil
}
origLoad := configLoadFn
defer func() { configLoadFn = origLoad }()
configLoadFn = func() (*config.Settings, error) {
return &config.Settings{}, nil
}
cmd := triggerAutoReviewCmd(3, "7", t.TempDir())
msg := cmd()
captured, ok := msg.(autoReviewCapturedMsg)
if !ok {
t.Fatalf("want autoReviewCapturedMsg, got %T", msg)
}
if captured.issueNumber != 3 {
t.Errorf("issueNumber = %d, want 3", captured.issueNumber)
}
if captured.sessionID != "rollout-session-xyz" {
t.Errorf("sessionID = %q, want rollout-session-xyz", captured.sessionID)
}
if gotSessionsDir == "" {
t.Error("expected the watcher to be called with a codex sessions dir")
}
}
// After capture, Update wires the persist cmd; running it must invoke MergeStateJSON
// to write reviewSessionId. We assert the captured-msg → persist-cmd handoff.
func TestUpdate_AutoReviewCapturedMsg_QueuesPersist(t *testing.T) {
m := Model{
webhookCh: make(chan webhook.Event, 1),
issues: []issue.Issue{{Number: 3}},
}
_, cmd := m.Update(autoReviewCapturedMsg{issueNumber: 3, sessionID: "sid-1"})
if cmd == nil {
t.Fatal("expected a persist cmd after capturing a non-empty session id")
}
}
// ── PrEvent opened dispatch ──────────────────────────────────────────────────
// PR opened resolves the issue number from the body (Closes #N) and queues the
// state-merge, conditional auto-review, and refresh cmds.
func TestDispatch_PrEvent_Opened_ResolvesIssueAndQueuesCmds(t *testing.T) {
m := Model{
webhookCh: make(chan webhook.Event, 1),
issues: []issue.Issue{{Number: 8, Branch: "feat/8"}},
}
var cmds []tea.Cmd
m, resultCmds := handlePrEvent(m, webhook.PrEvent{
Action: "opened",
PR: "12",
Branch: "feat/8",
Body: "Closes #8",
}, cmds)
// mergePRStateCmd, conditionalAutoReviewCmd, loadCmd.
if len(resultCmds) != 3 {
t.Fatalf("expected 3 cmds queued, got %d", len(resultCmds))
}
if !strings.Contains(m.statusMsg, "#8") {
t.Errorf("expected statusMsg to mention #8, got %q", m.statusMsg)
}
}
func TestDispatch_PrEvent_Opened_UnresolvableIssue_Warns(t *testing.T) {
m := Model{webhookCh: make(chan webhook.Event, 1)}
var cmds []tea.Cmd
m, resultCmds := handlePrEvent(m, webhook.PrEvent{
Action: "opened",
PR: "12",
Branch: "",
Body: "no closes keyword here",
}, cmds)
if len(resultCmds) != 0 {
t.Errorf("expected no cmds when issue is unresolvable, got %d", len(resultCmds))
}
if m.statusMsg == "" {
t.Error("expected a warning statusMsg when issue cannot be resolved")
}
}
// PR synchronize re-triggers review only when the matched issue already has a
// reviewSessionId.
func TestDispatch_PrEvent_Synchronize_WithReviewSession_Retriggers(t *testing.T) {
m := Model{
webhookCh: make(chan webhook.Event, 1),
issues: []issue.Issue{
{Number: 8, Branch: "feat/8", ReviewSessionID: "sid-old", WorktreePath: "wt/8"},
},
}
var cmds []tea.Cmd
m, resultCmds := handlePrEvent(m, webhook.PrEvent{
Action: "synchronize",
PR: "12",
Branch: "feat/8",
}, cmds)
// triggerAutoReviewCmd + loadCmd.
if len(resultCmds) != 2 {
t.Fatalf("expected 2 cmds (re-review + load), got %d", len(resultCmds))
}
if !strings.Contains(m.statusMsg, "更新审查") {
t.Errorf("expected update-review statusMsg, got %q", m.statusMsg)
}
}
func TestDispatch_PrEvent_Synchronize_NoReviewSession_OnlyRefreshes(t *testing.T) {
m := Model{
webhookCh: make(chan webhook.Event, 1),
issues: []issue.Issue{{Number: 8, Branch: "feat/8"}},
}
var cmds []tea.Cmd
_, resultCmds := handlePrEvent(m, webhook.PrEvent{
Action: "synchronize",
PR: "12",
Branch: "feat/8",
}, cmds)
// Only loadCmd.
if len(resultCmds) != 1 {
t.Fatalf("expected only the refresh cmd, got %d", len(resultCmds))
}
}
// ── PR closed/merged (Fix 2) ─────────────────────────────────────────────────
func TestDispatch_PrEvent_Closed_Merged_WritesPrMerged(t *testing.T) {
origMerge := mergeStateJSONFn
defer func() { mergeStateJSONFn = origMerge }()
var capturedExtra map[string]any
mergeStateJSONFn = func(_ context.Context, _ *gitea.Client, _, _ string, _ int, extra map[string]any) error {
capturedExtra = extra
return nil
}
origGet := getPullRequestFn
defer func() { getPullRequestFn = origGet }()
getPullRequestFn = func(_ context.Context, _ *gitea.Client, _, _ string, _ int) (*gitea.PullRequest, error) {
return &gitea.PullRequest{Merged: true, MergedAt: "2026-01-01T00:00:00Z"}, nil
}
origRepo := resolveGiteaRepoFn
defer func() { resolveGiteaRepoFn = origRepo }()
resolveGiteaRepoFn = func() (*giteaRepoCtx, error) {
return &giteaRepoCtx{client: &gitea.Client{}, owner: "o", repo: "r"}, nil
}
// Queue a closed event and check two cmds: prClosedCmd + loadCmd.
m := Model{webhookCh: make(chan webhook.Event, 1)}
var cmds []tea.Cmd
m, resultCmds := handlePrEvent(m, webhook.PrEvent{
Action: "closed",
PR: "5",
Branch: "feat/8",
Body: "Closes #8",
IssueNumber: 8,
}, cmds)
if len(resultCmds) != 2 {
t.Fatalf("expected prClosedCmd + loadCmd (2), got %d", len(resultCmds))
}
if !strings.Contains(m.statusMsg, "#8") {
t.Errorf("expected statusMsg to mention #8, got %q", m.statusMsg)
}
// Run the prClosedCmd (first in list). It calls getPullRequestFn (merged=true)
// and should invoke mergeStateJSONFn with prMerged=true.
msg := resultCmds[0]()
_ = msg
if capturedExtra == nil {
t.Fatal("expected mergeStateJSONFn to be called when PR is merged")
}
if v, ok := capturedExtra["prMerged"]; !ok || v != true {
t.Errorf("expected prMerged=true in state JSON, got %v", capturedExtra)
}
if _, ok := capturedExtra["prMergedAt"]; !ok {
t.Error("expected prMergedAt to be set")
}
}
func TestDispatch_PrEvent_Closed_NotMerged_NoWrite(t *testing.T) {
origMerge := mergeStateJSONFn
defer func() { mergeStateJSONFn = origMerge }()
called := false
mergeStateJSONFn = func(_ context.Context, _ *gitea.Client, _, _ string, _ int, _ map[string]any) error {
called = true
return nil
}
origGet := getPullRequestFn
defer func() { getPullRequestFn = origGet }()
getPullRequestFn = func(_ context.Context, _ *gitea.Client, _, _ string, _ int) (*gitea.PullRequest, error) {
return &gitea.PullRequest{Merged: false}, nil
}
origRepo := resolveGiteaRepoFn
defer func() { resolveGiteaRepoFn = origRepo }()
resolveGiteaRepoFn = func() (*giteaRepoCtx, error) {
return &giteaRepoCtx{client: &gitea.Client{}, owner: "o", repo: "r"}, nil
}
m := Model{webhookCh: make(chan webhook.Event, 1)}
var cmds []tea.Cmd
_, resultCmds := handlePrEvent(m, webhook.PrEvent{
Action: "closed",
PR: "5",
Branch: "feat/8",
Body: "Closes #8",
IssueNumber: 8,
}, cmds)
// Run the prClosedCmd.
resultCmds[0]()
if called {
t.Error("mergeStateJSONFn must NOT be called when PR is not merged")
}
}
// ── PR deleted (Fix 2) ───────────────────────────────────────────────────────
func TestDispatch_PrEvent_Deleted_ClearsPrField(t *testing.T) {
origMerge := mergeStateJSONFn
defer func() { mergeStateJSONFn = origMerge }()
var capturedExtra map[string]any
mergeStateJSONFn = func(_ context.Context, _ *gitea.Client, _, _ string, _ int, extra map[string]any) error {
capturedExtra = extra
return nil
}
origRepo := resolveGiteaRepoFn
defer func() { resolveGiteaRepoFn = origRepo }()
resolveGiteaRepoFn = func() (*giteaRepoCtx, error) {
return &giteaRepoCtx{client: &gitea.Client{}, owner: "o", repo: "r"}, nil
}
m := Model{webhookCh: make(chan webhook.Event, 1)}
var cmds []tea.Cmd
m, resultCmds := handlePrEvent(m, webhook.PrEvent{
Action: "deleted",
PR: "5",
Branch: "feat/8",
Body: "Closes #8",
IssueNumber: 8,
}, cmds)
if len(resultCmds) != 2 {
t.Fatalf("expected prDeletedCmd + loadCmd (2), got %d", len(resultCmds))
}
if !strings.Contains(m.statusMsg, "#8") {
t.Errorf("expected statusMsg to mention #8, got %q", m.statusMsg)
}
// Run the prDeletedCmd.
resultCmds[0]()
if capturedExtra == nil {
t.Fatal("expected mergeStateJSONFn to be called on PR deleted")
}
if v, ok := capturedExtra["pr"]; !ok || v != "" {
t.Errorf("expected pr=\"\" (clear) in state JSON, got %v", capturedExtra)
}
}
// ── issue edited / spec-plan sync (Fix 2) ────────────────────────────────────
func TestDispatch_IssueEvent_Edited_QueuesSpecPlanSync(t *testing.T) {
origMerge := mergeStateJSONFn
defer func() { mergeStateJSONFn = origMerge }()
var capturedExtra map[string]any
mergeStateJSONFn = func(_ context.Context, _ *gitea.Client, _, _ string, _ int, extra map[string]any) error {
capturedExtra = extra
return nil
}
origRepo := resolveGiteaRepoFn
defer func() { resolveGiteaRepoFn = origRepo }()
resolveGiteaRepoFn = func() (*giteaRepoCtx, error) {
return &giteaRepoCtx{client: &gitea.Client{}, owner: "o", repo: "r"}, nil
}
body := "<!-- spx:spec=docs/spec/feat-8.md --> <!-- spx:plan=docs/plan/feat-8.md -->"
m := Model{webhookCh: make(chan webhook.Event, 1)}
var cmds []tea.Cmd
m, resultCmds := handleIssueEvent(m, webhook.IssueEvent{
Action: "edited",
IssueNumber: 8,
Body: body,
}, cmds)
if len(resultCmds) == 0 {
t.Fatal("expected at least one cmd (syncIssueSpecPlanCmd) for edited action")
}
if !strings.Contains(m.statusMsg, "edited") {
t.Errorf("expected statusMsg to mention edited, got %q", m.statusMsg)
}
// Run the sync cmd.
resultCmds[0]()
if capturedExtra == nil {
t.Fatal("expected mergeStateJSONFn to be called with spec/plan")
}
if v, ok := capturedExtra["specFile"]; !ok || !strings.Contains(fmt.Sprint(v), "feat-8.md") {
t.Errorf("expected specFile to contain feat-8.md, got %v", capturedExtra)
}
if v, ok := capturedExtra["planFile"]; !ok || !strings.Contains(fmt.Sprint(v), "feat-8.md") {
t.Errorf("expected planFile to contain feat-8.md, got %v", capturedExtra)
}
}
func TestDispatch_IssueEvent_Edited_NoMarkers_NoCmd(t *testing.T) {
m := Model{webhookCh: make(chan webhook.Event, 1)}
var cmds []tea.Cmd
_, resultCmds := handleIssueEvent(m, webhook.IssueEvent{
Action: "edited",
IssueNumber: 9,
Body: "no markers here",
}, cmds)
// syncIssueSpecPlanCmd is queued but returns nil when run (no markers match).
if len(resultCmds) != 1 {
t.Fatalf("expected 1 cmd (syncIssueSpecPlanCmd, no-op), got %d", len(resultCmds))
}
// Run it — must not panic and must return nil (no gitea call needed).
msg := resultCmds[0]()
if msg != nil {
t.Errorf("expected nil msg when no markers found, got %T", msg)
}
}