Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
427 lines
13 KiB
Go
427 lines
13 KiB
Go
//go:build e2e
|
|
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
var cliCoverageMutex sync.Mutex
|
|
|
|
func runBDForCoverage(t *testing.T, dir string, args ...string) (stdout string, stderr string) {
|
|
t.Helper()
|
|
|
|
cliCoverageMutex.Lock()
|
|
defer cliCoverageMutex.Unlock()
|
|
|
|
// Add --no-daemon to all commands except init.
|
|
if len(args) > 0 && args[0] != "init" {
|
|
args = append([]string{"--no-daemon"}, args...)
|
|
}
|
|
|
|
oldStdout := os.Stdout
|
|
oldStderr := os.Stderr
|
|
oldDir, _ := os.Getwd()
|
|
oldArgs := os.Args
|
|
|
|
if err := os.Chdir(dir); err != nil {
|
|
t.Fatalf("chdir %s: %v", dir, err)
|
|
}
|
|
|
|
rOut, wOut, _ := os.Pipe()
|
|
rErr, wErr, _ := os.Pipe()
|
|
os.Stdout = wOut
|
|
os.Stderr = wErr
|
|
|
|
// Ensure direct mode.
|
|
oldNoDaemon, noDaemonWasSet := os.LookupEnv("BEADS_NO_DAEMON")
|
|
os.Setenv("BEADS_NO_DAEMON", "1")
|
|
defer func() {
|
|
if noDaemonWasSet {
|
|
_ = os.Setenv("BEADS_NO_DAEMON", oldNoDaemon)
|
|
} else {
|
|
os.Unsetenv("BEADS_NO_DAEMON")
|
|
}
|
|
}()
|
|
|
|
// Mark tests explicitly.
|
|
oldTestMode, testModeWasSet := os.LookupEnv("BEADS_TEST_MODE")
|
|
os.Setenv("BEADS_TEST_MODE", "1")
|
|
defer func() {
|
|
if testModeWasSet {
|
|
_ = os.Setenv("BEADS_TEST_MODE", oldTestMode)
|
|
} else {
|
|
os.Unsetenv("BEADS_TEST_MODE")
|
|
}
|
|
}()
|
|
|
|
// Ensure all commands (including init) operate on the temp workspace DB.
|
|
db := filepath.Join(dir, ".beads", "beads.db")
|
|
beadsDir := filepath.Join(dir, ".beads")
|
|
oldBeadsDir, beadsDirWasSet := os.LookupEnv("BEADS_DIR")
|
|
os.Setenv("BEADS_DIR", beadsDir)
|
|
defer func() {
|
|
if beadsDirWasSet {
|
|
_ = os.Setenv("BEADS_DIR", oldBeadsDir)
|
|
} else {
|
|
os.Unsetenv("BEADS_DIR")
|
|
}
|
|
}()
|
|
|
|
oldDB, dbWasSet := os.LookupEnv("BEADS_DB")
|
|
os.Setenv("BEADS_DB", db)
|
|
defer func() {
|
|
if dbWasSet {
|
|
_ = os.Setenv("BEADS_DB", oldDB)
|
|
} else {
|
|
os.Unsetenv("BEADS_DB")
|
|
}
|
|
}()
|
|
oldBDDB, bdDBWasSet := os.LookupEnv("BD_DB")
|
|
os.Setenv("BD_DB", db)
|
|
defer func() {
|
|
if bdDBWasSet {
|
|
_ = os.Setenv("BD_DB", oldBDDB)
|
|
} else {
|
|
os.Unsetenv("BD_DB")
|
|
}
|
|
}()
|
|
|
|
// Ensure actor is set so label operations record audit fields.
|
|
oldActor, actorWasSet := os.LookupEnv("BD_ACTOR")
|
|
os.Setenv("BD_ACTOR", "test-user")
|
|
defer func() {
|
|
if actorWasSet {
|
|
_ = os.Setenv("BD_ACTOR", oldActor)
|
|
} else {
|
|
os.Unsetenv("BD_ACTOR")
|
|
}
|
|
}()
|
|
oldBeadsActor, beadsActorWasSet := os.LookupEnv("BEADS_ACTOR")
|
|
os.Setenv("BEADS_ACTOR", "test-user")
|
|
defer func() {
|
|
if beadsActorWasSet {
|
|
_ = os.Setenv("BEADS_ACTOR", oldBeadsActor)
|
|
} else {
|
|
os.Unsetenv("BEADS_ACTOR")
|
|
}
|
|
}()
|
|
|
|
rootCmd.SetArgs(args)
|
|
os.Args = append([]string{"bd"}, args...)
|
|
|
|
err := rootCmd.Execute()
|
|
|
|
// Close and clean up all global state to prevent contamination between tests.
|
|
if store != nil {
|
|
store.Close()
|
|
store = nil
|
|
}
|
|
if daemonClient != nil {
|
|
daemonClient.Close()
|
|
daemonClient = nil
|
|
}
|
|
|
|
// Reset all global flags and state (keep aligned with integration cli_fast_test).
|
|
dbPath = ""
|
|
actor = ""
|
|
jsonOutput = false
|
|
noDaemon = false
|
|
noAutoFlush = false
|
|
noAutoImport = false
|
|
sandboxMode = false
|
|
noDb = false
|
|
autoFlushEnabled = true
|
|
storeActive = false
|
|
flushFailureCount = 0
|
|
lastFlushError = nil
|
|
if flushManager != nil {
|
|
_ = flushManager.Shutdown()
|
|
flushManager = nil
|
|
}
|
|
rootCtx = nil
|
|
rootCancel = nil
|
|
|
|
// Give SQLite time to release file locks.
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
_ = wOut.Close()
|
|
_ = wErr.Close()
|
|
os.Stdout = oldStdout
|
|
os.Stderr = oldStderr
|
|
_ = os.Chdir(oldDir)
|
|
os.Args = oldArgs
|
|
rootCmd.SetArgs(nil)
|
|
|
|
var outBuf, errBuf bytes.Buffer
|
|
_, _ = io.Copy(&outBuf, rOut)
|
|
_, _ = io.Copy(&errBuf, rErr)
|
|
_ = rOut.Close()
|
|
_ = rErr.Close()
|
|
|
|
stdout = outBuf.String()
|
|
stderr = errBuf.String()
|
|
|
|
if err != nil {
|
|
t.Fatalf("bd %v failed: %v\nStdout: %s\nStderr: %s", args, err, stdout, stderr)
|
|
}
|
|
|
|
return stdout, stderr
|
|
}
|
|
|
|
func extractJSONPayload(s string) string {
|
|
if i := strings.IndexAny(s, "[{"); i >= 0 {
|
|
return s[i:]
|
|
}
|
|
return s
|
|
}
|
|
|
|
func parseCreatedIssueID(t *testing.T, out string) string {
|
|
t.Helper()
|
|
|
|
p := extractJSONPayload(out)
|
|
var m map[string]interface{}
|
|
if err := json.Unmarshal([]byte(p), &m); err != nil {
|
|
t.Fatalf("parse create JSON: %v\n%s", err, out)
|
|
}
|
|
id, _ := m["id"].(string)
|
|
if id == "" {
|
|
t.Fatalf("missing id in create output: %s", out)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func TestCoverage_ShowUpdateClose(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping CLI coverage test in short mode")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
runBDForCoverage(t, dir, "init", "--prefix", "test", "--quiet")
|
|
|
|
out, _ := runBDForCoverage(t, dir, "create", "Show coverage issue", "-p", "1", "--json")
|
|
id := parseCreatedIssueID(t, out)
|
|
|
|
// Exercise update label flows (add -> set -> add/remove).
|
|
runBDForCoverage(t, dir, "update", id, "--add-label", "old", "--json")
|
|
runBDForCoverage(t, dir, "update", id, "--set-labels", "a,b", "--add-label", "c", "--remove-label", "a", "--json")
|
|
runBDForCoverage(t, dir, "update", id, "--remove-label", "old", "--json")
|
|
|
|
// Show JSON output and verify labels were applied.
|
|
showOut, _ := runBDForCoverage(t, dir, "show", "--allow-stale", id, "--json")
|
|
showPayload := extractJSONPayload(showOut)
|
|
|
|
var details []map[string]interface{}
|
|
if err := json.Unmarshal([]byte(showPayload), &details); err != nil {
|
|
// Some commands may emit a single object; fall back to object parse.
|
|
var single map[string]interface{}
|
|
if err2 := json.Unmarshal([]byte(showPayload), &single); err2 != nil {
|
|
t.Fatalf("parse show JSON: %v / %v\n%s", err, err2, showOut)
|
|
}
|
|
details = []map[string]interface{}{single}
|
|
}
|
|
if len(details) != 1 {
|
|
t.Fatalf("expected 1 issue, got %d", len(details))
|
|
}
|
|
labelsAny, ok := details[0]["labels"]
|
|
if !ok {
|
|
t.Fatalf("expected labels in show output: %s", showOut)
|
|
}
|
|
labelsBytes, _ := json.Marshal(labelsAny)
|
|
labelsStr := string(labelsBytes)
|
|
if !strings.Contains(labelsStr, "b") || !strings.Contains(labelsStr, "c") {
|
|
t.Fatalf("expected labels b and c, got %s", labelsStr)
|
|
}
|
|
if strings.Contains(labelsStr, "a") || strings.Contains(labelsStr, "old") {
|
|
t.Fatalf("expected labels a and old to be absent, got %s", labelsStr)
|
|
}
|
|
|
|
// Show text output.
|
|
showText, _ := runBDForCoverage(t, dir, "show", "--allow-stale", id)
|
|
if !strings.Contains(showText, "Show coverage issue") {
|
|
t.Fatalf("expected show output to contain title, got: %s", showText)
|
|
}
|
|
|
|
// Multi-ID show should print both issues.
|
|
out2, _ := runBDForCoverage(t, dir, "create", "Second issue", "-p", "2", "--json")
|
|
id2 := parseCreatedIssueID(t, out2)
|
|
multi, _ := runBDForCoverage(t, dir, "show", "--allow-stale", id, id2)
|
|
if !strings.Contains(multi, "Show coverage issue") || !strings.Contains(multi, "Second issue") {
|
|
t.Fatalf("expected multi-show output to include both titles, got: %s", multi)
|
|
}
|
|
if !strings.Contains(multi, "─") {
|
|
t.Fatalf("expected multi-show output to include a separator line, got: %s", multi)
|
|
}
|
|
|
|
// Close and verify JSON output.
|
|
closeOut, _ := runBDForCoverage(t, dir, "close", id, "--reason", "Done", "--json")
|
|
closePayload := extractJSONPayload(closeOut)
|
|
var closed []map[string]interface{}
|
|
if err := json.Unmarshal([]byte(closePayload), &closed); err != nil {
|
|
t.Fatalf("parse close JSON: %v\n%s", err, closeOut)
|
|
}
|
|
if len(closed) != 1 {
|
|
t.Fatalf("expected 1 closed issue, got %d", len(closed))
|
|
}
|
|
if status, _ := closed[0]["status"].(string); status != string(types.StatusClosed) {
|
|
t.Fatalf("expected status closed, got %q", status)
|
|
}
|
|
}
|
|
|
|
func TestCoverage_TemplateAndPinnedProtections(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping CLI coverage test in short mode")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
runBDForCoverage(t, dir, "init", "--prefix", "test", "--quiet")
|
|
|
|
// Create a pinned issue and verify close requires --force.
|
|
out, _ := runBDForCoverage(t, dir, "create", "Pinned issue", "-p", "1", "--json")
|
|
pinnedID := parseCreatedIssueID(t, out)
|
|
runBDForCoverage(t, dir, "update", pinnedID, "--status", string(types.StatusPinned), "--json")
|
|
_, closeErr := runBDForCoverage(t, dir, "close", pinnedID, "--reason", "Done")
|
|
if !strings.Contains(closeErr, "cannot close pinned issue") {
|
|
t.Fatalf("expected pinned close to be rejected, stderr: %s", closeErr)
|
|
}
|
|
|
|
forceOut, _ := runBDForCoverage(t, dir, "close", pinnedID, "--force", "--reason", "Done", "--json")
|
|
forcePayload := extractJSONPayload(forceOut)
|
|
var closed []map[string]interface{}
|
|
if err := json.Unmarshal([]byte(forcePayload), &closed); err != nil {
|
|
t.Fatalf("parse close JSON: %v\n%s", err, forceOut)
|
|
}
|
|
if len(closed) != 1 {
|
|
t.Fatalf("expected 1 closed issue, got %d", len(closed))
|
|
}
|
|
|
|
// Insert a template issue directly and verify update/close protect it.
|
|
dbFile := filepath.Join(dir, ".beads", "beads.db")
|
|
s, err := sqlite.New(context.Background(), dbFile)
|
|
if err != nil {
|
|
t.Fatalf("sqlite.New: %v", err)
|
|
}
|
|
ctx := context.Background()
|
|
template := &types.Issue{
|
|
Title: "Template issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
IsTemplate: true,
|
|
}
|
|
if err := s.CreateIssue(ctx, template, "test-user"); err != nil {
|
|
s.Close()
|
|
t.Fatalf("CreateIssue: %v", err)
|
|
}
|
|
created, err := s.GetIssue(ctx, template.ID)
|
|
if err != nil {
|
|
s.Close()
|
|
t.Fatalf("GetIssue(template): %v", err)
|
|
}
|
|
if created == nil || !created.IsTemplate {
|
|
s.Close()
|
|
t.Fatalf("expected inserted issue to be IsTemplate=true, got %+v", created)
|
|
}
|
|
_ = s.Close()
|
|
|
|
showOut, _ := runBDForCoverage(t, dir, "show", "--allow-stale", template.ID, "--json")
|
|
showPayload := extractJSONPayload(showOut)
|
|
var showDetails []map[string]interface{}
|
|
if err := json.Unmarshal([]byte(showPayload), &showDetails); err != nil {
|
|
t.Fatalf("parse show JSON: %v\n%s", err, showOut)
|
|
}
|
|
if len(showDetails) != 1 {
|
|
t.Fatalf("expected 1 issue from show, got %d", len(showDetails))
|
|
}
|
|
// Re-open the DB after running the CLI to confirm is_template persisted.
|
|
s2, err := sqlite.New(context.Background(), dbFile)
|
|
if err != nil {
|
|
t.Fatalf("sqlite.New (reopen): %v", err)
|
|
}
|
|
postShow, err := s2.GetIssue(context.Background(), template.ID)
|
|
_ = s2.Close()
|
|
if err != nil {
|
|
t.Fatalf("GetIssue(template, post-show): %v", err)
|
|
}
|
|
if postShow == nil || !postShow.IsTemplate {
|
|
t.Fatalf("expected template to remain IsTemplate=true post-show, got %+v", postShow)
|
|
}
|
|
if v, ok := showDetails[0]["is_template"]; ok {
|
|
if b, ok := v.(bool); !ok || !b {
|
|
t.Fatalf("expected show JSON is_template=true, got %v", v)
|
|
}
|
|
} else {
|
|
t.Fatalf("expected show JSON to include is_template=true, got: %s", showOut)
|
|
}
|
|
|
|
_, updErr := runBDForCoverage(t, dir, "update", template.ID, "--title", "New title")
|
|
if !strings.Contains(updErr, "cannot update template") {
|
|
t.Fatalf("expected template update to be rejected, stderr: %s", updErr)
|
|
}
|
|
_, closeTemplateErr := runBDForCoverage(t, dir, "close", template.ID, "--reason", "Done")
|
|
if !strings.Contains(closeTemplateErr, "cannot close template") {
|
|
t.Fatalf("expected template close to be rejected, stderr: %s", closeTemplateErr)
|
|
}
|
|
}
|
|
|
|
func TestCoverage_ShowThread(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping CLI coverage test in short mode")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
runBDForCoverage(t, dir, "init", "--prefix", "test", "--quiet")
|
|
|
|
dbFile := filepath.Join(dir, ".beads", "beads.db")
|
|
s, err := sqlite.New(context.Background(), dbFile)
|
|
if err != nil {
|
|
t.Fatalf("sqlite.New: %v", err)
|
|
}
|
|
ctx := context.Background()
|
|
|
|
root := &types.Issue{Title: "Root message", IssueType: types.TypeMessage, Status: types.StatusOpen, Sender: "alice", Assignee: "bob"}
|
|
reply1 := &types.Issue{Title: "Re: Root", IssueType: types.TypeMessage, Status: types.StatusOpen, Sender: "bob", Assignee: "alice"}
|
|
reply2 := &types.Issue{Title: "Re: Re: Root", IssueType: types.TypeMessage, Status: types.StatusOpen, Sender: "alice", Assignee: "bob"}
|
|
if err := s.CreateIssue(ctx, root, "test-user"); err != nil {
|
|
s.Close()
|
|
t.Fatalf("CreateIssue root: %v", err)
|
|
}
|
|
if err := s.CreateIssue(ctx, reply1, "test-user"); err != nil {
|
|
s.Close()
|
|
t.Fatalf("CreateIssue reply1: %v", err)
|
|
}
|
|
if err := s.CreateIssue(ctx, reply2, "test-user"); err != nil {
|
|
s.Close()
|
|
t.Fatalf("CreateIssue reply2: %v", err)
|
|
}
|
|
if err := s.AddDependency(ctx, &types.Dependency{IssueID: reply1.ID, DependsOnID: root.ID, Type: types.DepRepliesTo, ThreadID: root.ID}, "test-user"); err != nil {
|
|
s.Close()
|
|
t.Fatalf("AddDependency reply1->root: %v", err)
|
|
}
|
|
if err := s.AddDependency(ctx, &types.Dependency{IssueID: reply2.ID, DependsOnID: reply1.ID, Type: types.DepRepliesTo, ThreadID: root.ID}, "test-user"); err != nil {
|
|
s.Close()
|
|
t.Fatalf("AddDependency reply2->reply1: %v", err)
|
|
}
|
|
_ = s.Close()
|
|
|
|
out, _ := runBDForCoverage(t, dir, "show", "--allow-stale", reply2.ID, "--thread")
|
|
if !strings.Contains(out, "Thread") || !strings.Contains(out, "Total: 3 messages") {
|
|
t.Fatalf("expected thread output, got: %s", out)
|
|
}
|
|
if !strings.Contains(out, root.ID) || !strings.Contains(out, reply1.ID) || !strings.Contains(out, reply2.ID) {
|
|
t.Fatalf("expected thread output to include message IDs, got: %s", out)
|
|
}
|
|
}
|