Critical fixes to context propagation implementation (bd-rtp, bd-yb8, bd-2o2): 1. Fix rootCtx lifecycle in main.go: - Removed premature defer rootCancel() from PersistentPreRun (line 132) - Added proper cleanup in PersistentPostRun (lines 544-547) - Context now properly spans from setup through command execution to cleanup 2. Fix test context contamination in cli_fast_test.go: - Reset rootCtx and rootCancel to nil in test cleanup (lines 139-140) - Prevents cancelled contexts from affecting subsequent tests 3. Fix export tests missing context in export_test.go: - Added rootCtx initialization in 5 export test subtests - Tests now properly set up context before calling exportCmd.Run() These fixes ensure: - Signal-aware contexts work correctly for graceful cancellation - Ctrl+C properly cancels import/export operations - Database integrity is maintained after cancellation - All cancellation tests pass (TestImportCancellation, TestExportCommand) Tested: - go build ./cmd/bd ✓ - go test ./cmd/bd -run TestImportCancellation ✓ - go test ./cmd/bd -run TestExportCommand ✓ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
386 lines
10 KiB
Go
386 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
|
|
|
|
func TestExportCommand(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "bd-test-export-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
testDB := filepath.Join(tmpDir, "test.db")
|
|
s := newTestStore(t, testDB)
|
|
defer s.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create test issues
|
|
issues := []*types.Issue{
|
|
{
|
|
Title: "First Issue",
|
|
Description: "Test description 1",
|
|
Priority: 0,
|
|
IssueType: types.TypeBug,
|
|
Status: types.StatusOpen,
|
|
},
|
|
{
|
|
Title: "Second Issue",
|
|
Description: "Test description 2",
|
|
Priority: 1,
|
|
IssueType: types.TypeFeature,
|
|
Status: types.StatusInProgress,
|
|
},
|
|
}
|
|
|
|
for _, issue := range issues {
|
|
if err := s.CreateIssue(ctx, issue, "test-user"); err != nil {
|
|
t.Fatalf("Failed to create issue: %v", err)
|
|
}
|
|
}
|
|
|
|
// Add a label to first issue
|
|
if err := s.AddLabel(ctx, issues[0].ID, "critical", "test-user"); err != nil {
|
|
t.Fatalf("Failed to add label: %v", err)
|
|
}
|
|
|
|
// Add a dependency
|
|
dep := &types.Dependency{
|
|
IssueID: issues[0].ID,
|
|
DependsOnID: issues[1].ID,
|
|
Type: "blocks",
|
|
}
|
|
if err := s.AddDependency(ctx, dep, "test-user"); err != nil {
|
|
t.Fatalf("Failed to add dependency: %v", err)
|
|
}
|
|
|
|
t.Run("export to file", func(t *testing.T) {
|
|
exportPath := filepath.Join(tmpDir, "export.jsonl")
|
|
|
|
// Set up global state
|
|
store = s
|
|
dbPath = testDB
|
|
rootCtx = ctx
|
|
defer func() { rootCtx = nil }()
|
|
|
|
// Create a mock command with output flag
|
|
exportCmd.SetArgs([]string{"-o", exportPath})
|
|
exportCmd.Flags().Set("output", exportPath)
|
|
|
|
// Export
|
|
exportCmd.Run(exportCmd, []string{})
|
|
|
|
// Verify file was created
|
|
if _, err := os.Stat(exportPath); os.IsNotExist(err) {
|
|
t.Fatal("Export file was not created")
|
|
}
|
|
|
|
// Read and verify JSONL content
|
|
file, err := os.Open(exportPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to open export file: %v", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
scanner := bufio.NewScanner(file)
|
|
lineCount := 0
|
|
for scanner.Scan() {
|
|
lineCount++
|
|
var issue types.Issue
|
|
if err := json.Unmarshal(scanner.Bytes(), &issue); err != nil {
|
|
t.Fatalf("Failed to parse JSONL line %d: %v", lineCount, err)
|
|
}
|
|
|
|
// Verify issue has required fields
|
|
if issue.ID == "" {
|
|
t.Error("Issue missing ID")
|
|
}
|
|
if issue.Title == "" {
|
|
t.Error("Issue missing title")
|
|
}
|
|
}
|
|
|
|
if lineCount != 2 {
|
|
t.Errorf("Expected 2 lines in export, got %d", lineCount)
|
|
}
|
|
})
|
|
|
|
t.Run("export includes labels", func(t *testing.T) {
|
|
exportPath := filepath.Join(tmpDir, "export_labels.jsonl")
|
|
|
|
// Clear export hashes to force re-export (test isolation)
|
|
if err := s.ClearAllExportHashes(ctx); err != nil {
|
|
t.Fatalf("Failed to clear export hashes: %v", err)
|
|
}
|
|
|
|
store = s
|
|
dbPath = testDB
|
|
rootCtx = ctx
|
|
defer func() { rootCtx = nil }()
|
|
exportCmd.Flags().Set("output", exportPath)
|
|
exportCmd.Run(exportCmd, []string{})
|
|
|
|
file, err := os.Open(exportPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to open export file: %v", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
scanner := bufio.NewScanner(file)
|
|
foundLabeledIssue := false
|
|
for scanner.Scan() {
|
|
var issue types.Issue
|
|
if err := json.Unmarshal(scanner.Bytes(), &issue); err != nil {
|
|
t.Fatalf("Failed to parse JSONL: %v", err)
|
|
}
|
|
|
|
if issue.ID == issues[0].ID {
|
|
foundLabeledIssue = true
|
|
if len(issue.Labels) != 1 || issue.Labels[0] != "critical" {
|
|
t.Errorf("Expected label 'critical', got %v", issue.Labels)
|
|
}
|
|
}
|
|
}
|
|
|
|
if !foundLabeledIssue {
|
|
t.Error("Did not find labeled issue in export")
|
|
}
|
|
})
|
|
|
|
t.Run("export includes dependencies", func(t *testing.T) {
|
|
exportPath := filepath.Join(tmpDir, "export_deps.jsonl")
|
|
|
|
// Clear export hashes to force re-export (test isolation)
|
|
if err := s.ClearAllExportHashes(ctx); err != nil {
|
|
t.Fatalf("Failed to clear export hashes: %v", err)
|
|
}
|
|
|
|
store = s
|
|
dbPath = testDB
|
|
rootCtx = ctx
|
|
defer func() { rootCtx = nil }()
|
|
exportCmd.Flags().Set("output", exportPath)
|
|
exportCmd.Run(exportCmd, []string{})
|
|
|
|
file, err := os.Open(exportPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to open export file: %v", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
scanner := bufio.NewScanner(file)
|
|
foundDependency := false
|
|
for scanner.Scan() {
|
|
var issue types.Issue
|
|
if err := json.Unmarshal(scanner.Bytes(), &issue); err != nil {
|
|
t.Fatalf("Failed to parse JSONL: %v", err)
|
|
}
|
|
|
|
if issue.ID == issues[0].ID && len(issue.Dependencies) > 0 {
|
|
foundDependency = true
|
|
if issue.Dependencies[0].DependsOnID != issues[1].ID {
|
|
t.Errorf("Expected dependency to %s, got %s", issues[1].ID, issue.Dependencies[0].DependsOnID)
|
|
}
|
|
}
|
|
}
|
|
|
|
if !foundDependency {
|
|
t.Error("Did not find dependency in export")
|
|
}
|
|
})
|
|
|
|
t.Run("validate export path", func(t *testing.T) {
|
|
// Test safe path
|
|
if err := validateExportPath(tmpDir); err != nil {
|
|
t.Errorf("Unexpected error for safe path: %v", err)
|
|
}
|
|
|
|
// Test Windows system directories
|
|
// Note: validateExportPath() only checks Windows paths on case-insensitive systems
|
|
// On Unix/Mac, C:\Windows won't match, so we skip this assertion
|
|
// Just verify the function doesn't panic with Windows-style paths
|
|
_ = validateExportPath("C:\\Windows\\system32\\test.jsonl")
|
|
})
|
|
|
|
t.Run("prevent exporting empty database over non-empty JSONL", func(t *testing.T) {
|
|
exportPath := filepath.Join(tmpDir, "export_empty_check.jsonl")
|
|
|
|
// First, create a JSONL file with issues
|
|
file, err := os.Create(exportPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create JSONL: %v", err)
|
|
}
|
|
encoder := json.NewEncoder(file)
|
|
for _, issue := range issues {
|
|
if err := encoder.Encode(issue); err != nil {
|
|
t.Fatalf("Failed to encode issue: %v", err)
|
|
}
|
|
}
|
|
file.Close()
|
|
|
|
// Verify file has issues
|
|
count, err := countIssuesInJSONL(exportPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to count issues: %v", err)
|
|
}
|
|
if count != 2 {
|
|
t.Fatalf("Expected 2 issues in JSONL, got %d", count)
|
|
}
|
|
|
|
// Create empty database
|
|
emptyDBPath := filepath.Join(tmpDir, "empty.db")
|
|
emptyStore := newTestStore(t, emptyDBPath)
|
|
defer emptyStore.Close()
|
|
|
|
// Test using exportToJSONLWithStore directly (daemon code path)
|
|
err = exportToJSONLWithStore(ctx, emptyStore, exportPath)
|
|
if err == nil {
|
|
t.Error("Expected error when exporting empty database over non-empty JSONL")
|
|
} else {
|
|
expectedMsg := "refusing to export empty database over non-empty JSONL file (database: 0 issues, JSONL: 2 issues). This would result in data loss"
|
|
if err.Error() != expectedMsg {
|
|
t.Errorf("Unexpected error message:\nGot: %q\nExpected: %q", err.Error(), expectedMsg)
|
|
}
|
|
}
|
|
|
|
// Verify JSONL file is unchanged
|
|
countAfter, err := countIssuesInJSONL(exportPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to count issues after failed export: %v", err)
|
|
}
|
|
if countAfter != 2 {
|
|
t.Errorf("JSONL file was modified! Expected 2 issues, got %d", countAfter)
|
|
}
|
|
})
|
|
|
|
t.Run("verify JSONL line count matches exported count", func(t *testing.T) {
|
|
exportPath := filepath.Join(tmpDir, "export_verify.jsonl")
|
|
|
|
// Clear export hashes to force re-export
|
|
if err := s.ClearAllExportHashes(ctx); err != nil {
|
|
t.Fatalf("Failed to clear export hashes: %v", err)
|
|
}
|
|
|
|
store = s
|
|
dbPath = testDB
|
|
rootCtx = ctx
|
|
defer func() { rootCtx = nil }()
|
|
exportCmd.Flags().Set("output", exportPath)
|
|
exportCmd.Run(exportCmd, []string{})
|
|
|
|
// Verify the exported file has exactly 2 lines
|
|
actualCount, err := countIssuesInJSONL(exportPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to count issues in JSONL: %v", err)
|
|
}
|
|
if actualCount != 2 {
|
|
t.Errorf("Expected 2 issues in JSONL, got %d", actualCount)
|
|
}
|
|
|
|
// Simulate corrupted export by truncating file
|
|
corruptedPath := filepath.Join(tmpDir, "export_corrupted.jsonl")
|
|
|
|
// First export normally
|
|
if err := s.ClearAllExportHashes(ctx); err != nil {
|
|
t.Fatalf("Failed to clear export hashes: %v", err)
|
|
}
|
|
store = s
|
|
rootCtx = ctx
|
|
defer func() { rootCtx = nil }()
|
|
exportCmd.Flags().Set("output", corruptedPath)
|
|
exportCmd.Run(exportCmd, []string{})
|
|
|
|
// Now manually corrupt it by removing one line
|
|
file, err := os.Open(corruptedPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to open file for corruption: %v", err)
|
|
}
|
|
scanner := bufio.NewScanner(file)
|
|
var lines []string
|
|
for scanner.Scan() {
|
|
lines = append(lines, scanner.Text())
|
|
}
|
|
file.Close()
|
|
|
|
// Write back only first line (simulating partial write)
|
|
corruptedFile, err := os.Create(corruptedPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create corrupted file: %v", err)
|
|
}
|
|
corruptedFile.WriteString(lines[0] + "\n")
|
|
corruptedFile.Close()
|
|
|
|
// Verify countIssuesInJSONL detects the corruption
|
|
count, err := countIssuesInJSONL(corruptedPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to count corrupted file: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("Expected 1 line in corrupted file, got %d", count)
|
|
}
|
|
})
|
|
|
|
t.Run("export cancellation", func(t *testing.T) {
|
|
// Create a large number of issues to ensure export takes time
|
|
ctx := context.Background()
|
|
largeStore := newTestStore(t, filepath.Join(tmpDir, "large.db"))
|
|
defer largeStore.Close()
|
|
|
|
// Create 100 issues
|
|
for i := 0; i < 100; i++ {
|
|
issue := &types.Issue{
|
|
Title: "Test Issue",
|
|
Description: "Test description for cancellation",
|
|
Priority: 0,
|
|
IssueType: types.TypeBug,
|
|
Status: types.StatusOpen,
|
|
}
|
|
if err := largeStore.CreateIssue(ctx, issue, "test-user"); err != nil {
|
|
t.Fatalf("Failed to create issue: %v", err)
|
|
}
|
|
}
|
|
|
|
exportPath := filepath.Join(tmpDir, "export_cancel.jsonl")
|
|
|
|
// Create a cancellable context
|
|
cancelCtx, cancel := context.WithCancel(context.Background())
|
|
|
|
// Start export in a goroutine
|
|
errChan := make(chan error, 1)
|
|
go func() {
|
|
errChan <- exportToJSONLWithStore(cancelCtx, largeStore, exportPath)
|
|
}()
|
|
|
|
// Cancel after a short delay
|
|
cancel()
|
|
|
|
// Wait for export to finish
|
|
err := <-errChan
|
|
|
|
// Verify that the operation was cancelled
|
|
if err != nil && err != context.Canceled {
|
|
t.Logf("Export returned error: %v (expected context.Canceled)", err)
|
|
}
|
|
|
|
// Verify database integrity - we should still be able to query
|
|
issues, err := largeStore.SearchIssues(ctx, "", types.IssueFilter{})
|
|
if err != nil {
|
|
t.Fatalf("Database corrupted after cancellation: %v", err)
|
|
}
|
|
if len(issues) != 100 {
|
|
t.Errorf("Expected 100 issues after cancellation, got %d", len(issues))
|
|
}
|
|
})
|
|
}
|