Remove Gas Town-specific type constants (TypeMolecule, TypeGate, TypeConvoy, TypeMergeRequest, TypeSlot, TypeAgent, TypeRole, TypeRig, TypeEvent, TypeMessage) from internal/types/types.go. Beads now only has core work types built-in: - bug, feature, task, epic, chore All Gas Town types are now purely custom types with no special handling in beads. Use string literals like "gate" or "molecule" when needed, and configure types.custom in config.yaml for validation. Changes: - Remove Gas Town type constants from types.go - Remove mr/mol aliases from Normalize() - Update bd types command to only show core types - Replace all constant usages with string literals throughout codebase - Update tests to use string literals This decouples beads from Gas Town, making it a generic issue tracker. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.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: "message", Status: types.StatusOpen, Sender: "alice", Assignee: "bob"}
|
|
reply1 := &types.Issue{Title: "Re: Root", IssueType: "message", Status: types.StatusOpen, Sender: "bob", Assignee: "alice"}
|
|
reply2 := &types.Issue{Title: "Re: Re: Root", IssueType: "message", 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)
|
|
}
|
|
}
|