From a3b9e7a43441500e0117a4e0eadb0520789382bd Mon Sep 17 00:00:00 2001 From: hev Date: Sat, 10 Jan 2026 05:57:16 -0700 Subject: [PATCH 1/7] Add test mode for deterministic testing without Claude API calls Implement --test-mode flag that simulates the full Ralph loop without calling the real Claude CLI. This enables testing of Slack notifications, log output, and all phases (main loop, code review, cleanup, PR creation) in a deterministic way. Features: - New internal/testmode package with MockClaude that simulates todo progress - Three scenarios: success (default), error, partial - Writes real TODO.md files so existing parsing/tracking works unchanged - Exercises all notification hooks: SessionStart, TodoStarted, TodoCompleted, CodeReviewStarted/Complete, CleanupStarted/Complete, PRCreated, SessionEnd - Demonstrates all log types (standard, verbose, error, success) Also includes: - Sound playback support with Ralph Wiggum quotes - Version injection via ldflags in Makefile - GitHub Actions CI/CD workflows - GoReleaser configuration for releases Slack: https://hevmindworkspace.slack.com/archives/C0A6L0UFU6R/p1768048187219089 Co-Authored-By: Claude Opus 4.5 --- .agent/TODO.md | 19 --- .github/workflows/ci.yml | 105 ++++++++++++ .github/workflows/release.yml | 37 ++++ .gitignore | 1 + .goreleaser.yml | 89 ++++++++++ Makefile | 41 ++++- cmd/ralph/main.go | 35 ++++ internal/config/config.go | 116 ++++++++++++- internal/metrics/collector.go | 6 + internal/runner/runner.go | 115 +++++++++++-- internal/testmode/mock_claude.go | 209 +++++++++++++++++++++++ internal/testmode/mock_claude_test.go | 235 ++++++++++++++++++++++++++ 12 files changed, 969 insertions(+), 39 deletions(-) delete mode 100644 .agent/TODO.md create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .goreleaser.yml create mode 100644 internal/testmode/mock_claude.go create mode 100644 internal/testmode/mock_claude_test.go diff --git a/.agent/TODO.md b/.agent/TODO.md deleted file mode 100644 index 4f249fe..0000000 --- a/.agent/TODO.md +++ /dev/null @@ -1,19 +0,0 @@ -# Code Review - -## Issues Found - -- [ ] internal/metrics/collector_test.go:343-364 - Data Race Risk: TestCollector_ConcurrentUpdates writes to collector.currentPending and collector.currentComplete from multiple goroutines without synchronization. The underlying UpdateTodoCounts method lacks thread-safety. Consider adding mutex protection in the Collector struct or documenting that UpdateTodoCounts is not thread-safe. - -- [ ] internal/runner/runner_test.go:467-486 - Unused MockTracker: The MockTracker struct is defined but never used in any test. This appears to be dead code that should either be removed or tests should be added that use it. - -- [ ] internal/runner/runner_test.go - Missing t.Parallel(): TestGeneratePRTitle, TestGeneratePRBody, TestCopyFile, and other tests don't use t.Parallel() while some tests in the same file do. Consider adding t.Parallel() to all table-driven tests that don't modify shared state for faster test execution and consistency. - -- [ ] internal/git/tracker_test.go, internal/git/pr_test.go, internal/worktree/worktree_test.go - Code Duplication: The helper functions setupTestGitRepo() and makeCommit() are duplicated across multiple test files. Consider extracting these to a shared testutil package to reduce duplication and improve maintainability. - -- [ ] internal/slack/client_test.go:708-720 - Inefficient containsSubstring Helper: The containsSubstring helper function reimplements strings.Contains with a custom containsHelper. This should simply use strings.Contains from the standard library for clarity and correctness. - -- [ ] internal/claude/client_test.go - Resource Leak Risk: Tests like TestClient_WithContext_Integration, TestClient_Kill_RunningProcess, and TestClient_ContextCancellation create real exec.Command processes but some test paths may not properly clean up the process (e.g., if assertions fail before cleanup). Consider using t.Cleanup() for more robust resource management. - -- [ ] internal/runner/runner_test.go - Test File Path Changes Current Directory: Tests like TestRunCleanupPhase_WithPatterns use os.Chdir to change directories and rely on defer to restore. If a test panics, this could affect other tests. Consider using t.Chdir() (Go 1.24+) or restructuring tests to avoid directory changes. - -- [ ] internal/runner/runner_test.go:1330-1381 - Platform-Specific Test Assumption: TestRunCleanupPhase_FileRemovalError sets file to read-only (0444) expecting removal to fail, but this behavior varies by OS and user permissions (root can still delete). The test correctly handles this with "may or may not produce error" but could be marked with build tags or skip conditions for clarity. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6bb074b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,105 @@ +name: CI + +on: + push: + branches: + - main + - master + pull_request: + branches: + - main + - master + +permissions: + contents: read + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + cache: true + + - name: Download dependencies + run: go mod download + + - name: Verify dependencies + run: go mod verify + + - name: Run go vet + run: go vet ./... + + - name: Run tests + run: go test -v -race -coverprofile=coverage.out ./... + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage.out + retention-days: 7 + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + cache: true + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest + + build: + name: Build + runs-on: ubuntu-latest + needs: [test, lint] + strategy: + matrix: + goos: [linux, darwin, windows] + goarch: [amd64, arm64] + exclude: + - goos: windows + goarch: arm64 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + cache: true + + - name: Build binary + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + run: | + mkdir -p build + binary_name="ralph" + if [ "$GOOS" = "windows" ]; then + binary_name="ralph.exe" + fi + go build -ldflags="-s -w" -o "build/${binary_name}" ./cmd/ralph + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: ralph-${{ matrix.goos }}-${{ matrix.goarch }} + path: build/ + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2167948 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,37 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + +permissions: + contents: write + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + cache: true + + - name: Run tests + run: go test -v -race ./... + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: '~> v2' + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index ad3b628..26dabac 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ # Build output build/ +dist/ ralph # Go diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..4957f12 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,89 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +version: 2 + +project_name: ralph + +before: + hooks: + - go mod tidy + - go mod verify + +builds: + - id: ralph + main: ./cmd/ralph + binary: ralph + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ignore: + - goos: windows + goarch: arm64 + ldflags: + - -s -w + - -X github.com/hev/ralph/internal/config.Version={{.Version}} + - -X github.com/hev/ralph/internal/config.Commit={{.Commit}} + - -X github.com/hev/ralph/internal/config.Date={{.Date}} + mod_timestamp: '{{ .CommitTimestamp }}' + +archives: + - id: default + format: tar.gz + name_template: >- + {{ .ProjectName }}_ + {{- .Version }}_ + {{- .Os }}_ + {{- .Arch }} + {{- if .Arm }}v{{ .Arm }}{{ end }} + format_overrides: + - goos: windows + format: zip + files: + - README.md + - LICENSE* + +checksum: + name_template: 'checksums.txt' + algorithm: sha256 + +snapshot: + version_template: "{{ incpatch .Version }}-next" + +changelog: + sort: asc + use: github + filters: + exclude: + - '^docs:' + - '^test:' + - '^ci:' + - '^chore:' + - Merge pull request + - Merge branch + groups: + - title: Features + regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$' + order: 0 + - title: Bug Fixes + regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$' + order: 1 + - title: Other + order: 999 + +release: + github: + owner: hev + name: ralph + draft: false + prerelease: auto + mode: append + header: | + ## Ralph {{ .Version }} + footer: | + **Full Changelog**: https://github.com/hev/ralph/compare/{{ .PreviousTag }}...{{ .Tag }} + name_template: "v{{.Version}}" diff --git a/Makefile b/Makefile index 11eac06..d1846ff 100644 --- a/Makefile +++ b/Makefile @@ -1,28 +1,41 @@ -.PHONY: build install clean test run run-otel up down logs +.PHONY: build install clean test test-race run run-otel up down logs snapshot release # Build variables BINARY_NAME=ralph -VERSION=1.0.0 +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") +COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") +DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") BUILD_DIR=./build GO_FILES=$(shell find . -type f -name '*.go') +# ldflags for version injection +LDFLAGS=-s -w \ + -X github.com/hev/ralph/internal/config.Version=$(VERSION) \ + -X github.com/hev/ralph/internal/config.Commit=$(COMMIT) \ + -X github.com/hev/ralph/internal/config.Date=$(DATE) + # Build the binary build: - go build -ldflags="-s -w" -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/ralph + go build -ldflags="$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/ralph # Install to GOPATH/bin install: - go install ./cmd/ralph + go install -ldflags="$(LDFLAGS)" ./cmd/ralph # Clean build artifacts clean: rm -rf $(BUILD_DIR) + rm -rf dist/ go clean # Run tests test: go test -v ./... +# Run tests with race detector +test-race: + go test -v -race ./... + # Run ralph with default settings run: build $(BUILD_DIR)/$(BINARY_NAME) @@ -47,10 +60,19 @@ logs: # Build for multiple platforms build-all: - GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./cmd/ralph - GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./cmd/ralph - GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 ./cmd/ralph - GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./cmd/ralph + GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./cmd/ralph + GOOS=linux GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./cmd/ralph + GOOS=darwin GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 ./cmd/ralph + GOOS=darwin GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./cmd/ralph + GOOS=windows GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./cmd/ralph + +# Create a snapshot release (for testing) +snapshot: + goreleaser release --snapshot --clean + +# Create a release (requires GITHUB_TOKEN) +release: + goreleaser release --clean # Show help help: @@ -59,9 +81,12 @@ help: @echo " install - Install ralph to GOPATH/bin" @echo " clean - Remove build artifacts" @echo " test - Run tests" + @echo " test-race - Run tests with race detector" @echo " run - Build and run ralph" @echo " run-otel - Build and run ralph with OTEL enabled" @echo " up - Start the observability stack (docker-compose)" @echo " down - Stop the observability stack" @echo " logs - View docker-compose logs" @echo " build-all - Build for multiple platforms" + @echo " snapshot - Create a snapshot release (testing)" + @echo " release - Create a release with goreleaser" diff --git a/cmd/ralph/main.go b/cmd/ralph/main.go index f4c8c16..7ac2508 100644 --- a/cmd/ralph/main.go +++ b/cmd/ralph/main.go @@ -87,6 +87,14 @@ func init() { rootCmd.Flags().StringVarP(&cfg.WorktreeBranch, "branch", "b", cfg.WorktreeBranch, "Branch name for worktree (empty = auto-generate)") rootCmd.Flags().BoolVarP(&cfg.WorktreeCleanup, "keep-worktree", "k", false, "Keep worktree after completion (inverts cleanup)") + // Sound options + rootCmd.Flags().BoolVar(&cfg.SoundEnabled, "sound", cfg.SoundEnabled, "Play Ralph Wiggum quotes after each iteration") + rootCmd.Flags().BoolVar(&cfg.SoundMute, "sound-mute", cfg.SoundMute, "Mute sound playback (or RALPH_SOUND_MUTE=1 env)") + + // Test mode options + rootCmd.Flags().BoolVar(&cfg.TestMode, "test-mode", cfg.TestMode, "Run in test mode (mock Claude, simulate todo progress)") + rootCmd.Flags().StringVar(&cfg.TestScenario, "test-scenario", cfg.TestScenario, "Test scenario: success, error, partial") + // Config file flag rootCmd.Flags().StringVar(&configFile, "config", "", "Path to config file (default: ./ralph.yaml or ~/.config/ralph/ralph.yaml)") @@ -156,6 +164,14 @@ func init() { savedValues["branch"] = cfg.WorktreeBranch case "keep-worktree": savedValues["keep-worktree"] = true // Flag was set, invert cleanup + case "sound": + savedValues["sound"] = cfg.SoundEnabled + case "sound-mute": + savedValues["sound-mute"] = cfg.SoundMute + case "test-mode": + savedValues["test-mode"] = cfg.TestMode + case "test-scenario": + savedValues["test-scenario"] = cfg.TestScenario } }) @@ -239,6 +255,14 @@ func init() { cfg.WorktreeBranch = val.(string) case "keep-worktree": cfg.WorktreeCleanup = false // -k inverts cleanup + case "sound": + cfg.SoundEnabled = val.(bool) + case "sound-mute": + cfg.SoundMute = val.(bool) + case "test-mode": + cfg.TestMode = val.(bool) + case "test-scenario": + cfg.TestScenario = val.(string) } } @@ -308,6 +332,14 @@ Worktree Options: -b, --branch NAME Branch name for worktree (default: auto-generate) -k, --keep-worktree Keep worktree after completion (default: false) +Sound Options: + --sound Play Ralph Wiggum quotes after each iteration (default: false) + --sound-mute Mute sound playback (or RALPH_SOUND_MUTE=1 env) + +Test Mode Options: + --test-mode Run in test mode (mock Claude, simulate todo progress) + --test-scenario SCENARIO Test scenario: success, error, partial (default: success) + Examples: ralph # Run forever with defaults ralph -n 5 # Run for 5 iterations @@ -324,6 +356,9 @@ Examples: ralph -s --code-review --cleanup # Full pipeline: work, review, cleanup ralph -s --pr # Stop on completion, create PR ralph -w -s --pr # Worktree + stop + PR (common pattern) + ralph --sound # Play Ralph Wiggum quotes after each iteration + ralph --test-mode # Run in test mode (mock Claude) + ralph --test-mode --slack-enabled # Test mode with Slack notifications "I'm in danger!" - Ralph Wiggum `, config.Version)) diff --git a/internal/config/config.go b/internal/config/config.go index 1cc844c..fa12fb6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,7 +9,11 @@ import ( "gopkg.in/yaml.v3" ) -const Version = "0.1.0" +var ( + Version = "dev" + Commit = "unknown" + Date = "unknown" +) // Config holds all configuration for ralph type Config struct { @@ -64,11 +68,25 @@ type Config struct { PRTitle string // Custom title for the PR (empty = auto-generate) PRBase string // Base branch for the PR (empty = default branch) + // Prompt options + ScratchpadPrompt string // Custom scratchpad instructions (appended to prompt) + + // Sound options + SoundEnabled bool // Play Ralph Wiggum quotes after each iteration + SoundMute bool // Mute sound playback (respects RALPH_SOUND_MUTE env) + SoundPageURL string // URL to fetch sound clips from + SoundPlayer string // Preferred audio player (afplay, ffplay, mpg123, mpg321) + SoundCacheDir string // Directory to cache sound URLs + // Session info SessionID string // Config file path (set by --config flag) ConfigFile string + + // Test mode options + TestMode bool // Run in test mode (mock Claude, simulate todo progress) + TestScenario string // Test scenario: "success", "error", "partial" } // yamlConfig represents the YAML file structure @@ -124,6 +142,21 @@ type yamlConfig struct { Title string `yaml:"title"` Base string `yaml:"base"` } `yaml:"pr"` + + Sound struct { + Enabled *bool `yaml:"enabled"` + Mute *bool `yaml:"mute"` + PageURL string `yaml:"page_url"` + Player string `yaml:"player"` + CacheDir string `yaml:"cache_dir"` + } `yaml:"sound"` + + ScratchpadPrompt string `yaml:"scratchpad_prompt"` + + TestMode struct { + Enabled *bool `yaml:"enabled"` + Scenario string `yaml:"scenario"` + } `yaml:"test_mode"` } // DefaultConfig returns a Config with default values matching the bash script @@ -177,8 +210,28 @@ func DefaultConfig() *Config { PRTitle: "", PRBase: "", + ScratchpadPrompt: DefaultScratchpadPrompt, + + SoundEnabled: false, + SoundMute: getEnvOrDefault("RALPH_SOUND_MUTE", "") == "1", + SoundPageURL: getEnvOrDefault("RALPH_SOUND_PAGE_URL", "https://andrewziola.com/xoom/wiggum/"), + SoundPlayer: getEnvOrDefault("RALPH_SOUND_PLAYER", ""), + SoundCacheDir: getSoundCacheDir(), + SessionID: uuid.New().String(), + + TestMode: false, + TestScenario: "success", + } +} + +// getSoundCacheDir returns the cache directory for sound files +func getSoundCacheDir() string { + cacheDir, _ := os.UserCacheDir() + if cacheDir == "" { + cacheDir = "/tmp" } + return filepath.Join(cacheDir, "ralph") } // GetSlackNotifyUsers returns the notify users as a slice @@ -205,9 +258,18 @@ func getEnvOrDefault(key, defaultVal string) string { return defaultVal } +// DefaultScratchpadPrompt is the default prompt appended to instructions +const DefaultScratchpadPrompt = "Use the {{.AgentDir}} directory as a scratchpad for your work. Keep track of your current status in {{.AgentDir}}/TODO.md using checkboxes (- [ ] for pending, - [x] for done). Check off items when completed. Only work on a single item at a time and end your session when complete. Make a commit and push your changes after every single file edit." + // ScratchpadInstructions returns the instructions appended to prompts +// Supports {{.AgentDir}} template substitution in the prompt func (c *Config) ScratchpadInstructions() string { - return "\n\nUse the " + c.AgentDir + " directory as a scratchpad for your work. Keep track of your current status in " + c.AgentDir + "/TODO.md using checkboxes (- [ ] for pending, - [x] for done). Check off items when completed. Only work on a single item at a time and end your session when complete. Make a commit and push your changes after every single file edit." + prompt := c.ScratchpadPrompt + if prompt == "" { + prompt = DefaultScratchpadPrompt + } + prompt = strings.ReplaceAll(prompt, "{{.AgentDir}}", c.AgentDir) + return "\n\n" + prompt } // CodeReviewInstructions returns the default code review prompt @@ -402,5 +464,55 @@ func (c *Config) LoadFromFile(path string) error { c.PRBase = yc.PR.Base } + // Sound options + if yc.Sound.Enabled != nil { + c.SoundEnabled = *yc.Sound.Enabled + } + if yc.Sound.Mute != nil { + c.SoundMute = *yc.Sound.Mute + } + if yc.Sound.PageURL != "" { + c.SoundPageURL = yc.Sound.PageURL + } + if yc.Sound.Player != "" { + c.SoundPlayer = yc.Sound.Player + } + if yc.Sound.CacheDir != "" { + c.SoundCacheDir = yc.Sound.CacheDir + } + + // Prompt options + if yc.ScratchpadPrompt != "" { + c.ScratchpadPrompt = yc.ScratchpadPrompt + } + + // Test mode options + if yc.TestMode.Enabled != nil { + c.TestMode = *yc.TestMode.Enabled + } + if yc.TestMode.Scenario != "" { + c.TestScenario = yc.TestMode.Scenario + } + return nil } + +// SoundConfig holds configuration for Ralph sound playback +type SoundConfig struct { + Enabled bool + Mute bool + PageURL string + Player string + CacheDir string +} + +// GetSoundConfig returns the sound configuration +func (c *Config) GetSoundConfig() SoundConfig { + return SoundConfig{ + Enabled: c.SoundEnabled, + Mute: c.SoundMute, + PageURL: c.SoundPageURL, + Player: c.SoundPlayer, + CacheDir: c.SoundCacheDir, + } +} diff --git a/internal/metrics/collector.go b/internal/metrics/collector.go index fd76d84..c786535 100644 --- a/internal/metrics/collector.go +++ b/internal/metrics/collector.go @@ -263,6 +263,12 @@ func (c *Collector) Shutdown(ctx context.Context) error { return nil } + // Force flush to ensure all pending metrics are exported + if err := c.meterProvider.ForceFlush(ctx); err != nil { + // Log but continue with shutdown + _ = err + } + return c.meterProvider.Shutdown(ctx) } diff --git a/internal/runner/runner.go b/internal/runner/runner.go index bc78a04..8a6ee09 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -17,7 +17,9 @@ import ( "github.com/hev/ralph/internal/config" "github.com/hev/ralph/internal/git" "github.com/hev/ralph/internal/metrics" + "github.com/hev/ralph/internal/ralph" "github.com/hev/ralph/internal/slack" + "github.com/hev/ralph/internal/testmode" "github.com/hev/ralph/internal/worktree" ) @@ -163,6 +165,38 @@ func Run(cfg *config.Config) error { return nil } + // Test mode setup + var mockClaude *testmode.MockClaude + if cfg.TestMode { + log("=== TEST MODE ENABLED ===") + log("Scenario: %s", cfg.TestScenario) + logVerbose(cfg, "Mock Claude will simulate todo progress") + + mockClaude = testmode.NewMockClaude(cfg.TestScenario, cfg.AgentDir) + + // Set reasonable defaults for test mode + if cfg.MaxIterations == 0 { + cfg.MaxIterations = 5 // Default test iterations + } + + // Enable all phases for success scenario to exercise full flow + if cfg.TestScenario == "success" { + cfg.StopOnCompletion = true + cfg.CodeReviewEnabled = true + cfg.CleanupEnabled = true + cfg.PREnabled = true + } + + // Auto-enable sound in test mode (can still be muted with --sound-mute) + cfg.SoundEnabled = true + + // Demonstrate all log types in test mode + log("This is a standard log message") + logVerbose(cfg, "This is a verbose log message") + logError("This is an error log message (test only)") + logSuccess("This is a success log message") + } + // Initialize metrics tracker tracker, err := metrics.NewTracker(cfg) if err != nil { @@ -183,6 +217,12 @@ func Run(cfg *config.Config) error { MaxTime: cfg.MaxTime, }) + // Initialize sound player + soundPlayer := ralph.NewSoundPlayer(cfg.GetSoundConfig()) + if cfg.SoundEnabled { + logVerbose(cfg, "Ralph sounds enabled") + } + // Setup context with cancellation for graceful shutdown ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -257,8 +297,17 @@ func Run(cfg *config.Config) error { } iterationStart := time.Now() - // Run claude with streaming output - exitCode, err := runClaude(ctx, fullPrompt, cfg.Model) + // Run claude with streaming output (or mock in test mode) + var exitCode int + if mockClaude != nil { + exitCode, err = mockClaude.RunIteration(ctx) + // Stream mock output + for line := range mockClaude.StreamOutput() { + claude.ParseAndPrint(line) + } + } else { + exitCode, err = runClaude(ctx, fullPrompt, cfg.Model) + } iterationDuration := time.Since(iterationStart) hadError := err != nil @@ -275,6 +324,11 @@ func Run(cfg *config.Config) error { logSuccess("Iteration %d complete", iteration) } + // Play Ralph Wiggum quote after each iteration + if err := soundPlayer.Play(); err != nil { + logVerbose(cfg, "Failed to play sound: %v", err) + } + // Record iteration metrics if tracker != nil { tracker.AfterIteration(ctx, iterationDuration, hadError, "complete") @@ -332,7 +386,7 @@ func Run(cfg *config.Config) error { // Run code review phase if enabled and todos completed if cfg.CodeReviewEnabled && exitReason == "all todos complete" { - reviewExitReason, reviewIters := runCodeReviewPhase(ctx, cfg, notifier, tracker) + reviewExitReason, reviewIters := runCodeReviewPhase(ctx, cfg, notifier, tracker, soundPlayer, mockClaude) exitReason = reviewExitReason iteration += reviewIters } @@ -363,17 +417,24 @@ func Run(cfg *config.Config) error { // runCodeReviewPhase runs the code review loop after todos are complete // Returns the exit reason and number of iterations -func runCodeReviewPhase(ctx context.Context, cfg *config.Config, notifier *slack.Notifier, tracker *metrics.Tracker) (string, int) { +func runCodeReviewPhase(ctx context.Context, cfg *config.Config, notifier *slack.Notifier, tracker *metrics.Tracker, soundPlayer *ralph.SoundPlayer, mockClaude *testmode.MockClaude) (string, int) { log("=== Starting Code Review Phase ===") + // Set mock to code review phase if in test mode + if mockClaude != nil { + mockClaude.SetPhase("code_review") + } + // Get the code review prompt reviewPrompt := cfg.CodeReviewInstructions() - // Clear the TODO file to prepare for review issues + // Clear the TODO file to prepare for review issues (skip in test mode, mock will write it) todoPath := filepath.Join(cfg.AgentDir, "TODO.md") - if err := os.WriteFile(todoPath, []byte("# Code Review\n\n## Issues Found\n\n"), 0644); err != nil { - logError("Failed to clear TODO file for code review: %v", err) - return "code review setup failed", 0 + if mockClaude == nil { + if err := os.WriteFile(todoPath, []byte("# Code Review\n\n## Issues Found\n\n"), 0644); err != nil { + logError("Failed to clear TODO file for code review: %v", err) + return "code review setup failed", 0 + } } // Reset todo tracking for review phase @@ -409,13 +470,23 @@ func runCodeReviewPhase(ctx context.Context, cfg *config.Config, notifier *slack } iterationStart := time.Now() - // Run claude with review prompt + // Run claude with review prompt (or mock in test mode) // Use code review model if specified, otherwise fall back to main model model := cfg.CodeReviewModel if model == "" { model = cfg.Model } - exitCode, err := runClaude(ctx, reviewPrompt, model) + var exitCode int + var err error + if mockClaude != nil { + exitCode, err = mockClaude.RunIteration(ctx) + // Stream mock output + for line := range mockClaude.StreamOutput() { + claude.ParseAndPrint(line) + } + } else { + exitCode, err = runClaude(ctx, reviewPrompt, model) + } iterationDuration := time.Since(iterationStart) hadError := err != nil @@ -431,6 +502,13 @@ func runCodeReviewPhase(ctx context.Context, cfg *config.Config, notifier *slack logSuccess("Code review iteration %d complete", reviewIteration) } + // Play Ralph Wiggum quote after each code review iteration + if soundPlayer != nil { + if err := soundPlayer.Play(); err != nil { + logVerbose(cfg, "Failed to play sound: %v", err) + } + } + // Record iteration metrics if tracker != nil { tracker.AfterIteration(ctx, iterationDuration, hadError, "code_review") @@ -569,6 +647,23 @@ func runCleanupPhase(ctx context.Context, cfg *config.Config, notifier *slack.No func runPRPhase(ctx context.Context, cfg *config.Config, notifier *slack.Notifier, tracker *metrics.Tracker) (string, error) { log("=== Starting PR Creation Phase ===") + // Test mode: simulate PR creation + if cfg.TestMode { + log("Test mode: Simulating PR creation...") + prURL := "https://github.com/test/repo/pull/999" + prTitle := "Test PR Title" + logSuccess("PR created (simulated): %s", prURL) + + // Send Slack notification with simulated URL + if notifier.IsEnabled() { + if err := notifier.PRCreated(ctx, prURL, prTitle); err != nil { + logError("Failed to send Slack PR notification: %v", err) + } + } + + return prURL, nil + } + // Check if we're on a branch other than default currentBranch, err := git.GetCurrentBranch() if err != nil { diff --git a/internal/testmode/mock_claude.go b/internal/testmode/mock_claude.go new file mode 100644 index 0000000..5db6d03 --- /dev/null +++ b/internal/testmode/mock_claude.go @@ -0,0 +1,209 @@ +package testmode + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" +) + +// Scenario defines the test behavior +type Scenario string + +const ( + ScenarioSuccess Scenario = "success" + ScenarioError Scenario = "error" + ScenarioPartial Scenario = "partial" +) + +// MockClaude simulates Claude CLI behavior for test mode +type MockClaude struct { + scenario Scenario + iteration int + agentDir string + phase string // "main", "code_review" +} + +// NewMockClaude creates a new mock Claude client +func NewMockClaude(scenario string, agentDir string) *MockClaude { + return &MockClaude{ + scenario: Scenario(scenario), + agentDir: agentDir, + phase: "main", + } +} + +// SetPhase sets the current phase for appropriate todo generation +func (m *MockClaude) SetPhase(phase string) { + m.phase = phase + if phase == "code_review" { + m.iteration = 0 // Reset iteration for code review phase + } +} + +// GetIteration returns the current iteration number +func (m *MockClaude) GetIteration() int { + return m.iteration +} + +// RunIteration simulates a single Claude iteration +// Returns (exitCode, error) +func (m *MockClaude) RunIteration(ctx context.Context) (int, error) { + m.iteration++ + + // Check context cancellation + select { + case <-ctx.Done(): + return -1, ctx.Err() + default: + } + + // Simulate work duration (deterministic for testing) + duration := time.Duration(m.iteration*100) * time.Millisecond + time.Sleep(duration) + + // Handle error scenario + if m.scenario == ScenarioError && m.iteration == 3 { + return 1, fmt.Errorf("simulated Claude error on iteration 3") + } + + // Update TODO.md based on phase and iteration + if err := m.updateTodos(); err != nil { + return -1, err + } + + return 0, nil +} + +// updateTodos writes appropriate TODO.md content for current iteration +func (m *MockClaude) updateTodos() error { + todoPath := filepath.Join(m.agentDir, "TODO.md") + + var content string + + switch m.phase { + case "main": + content = m.getMainPhaseTodos() + case "code_review": + content = m.getCodeReviewTodos() + default: + content = m.getMainPhaseTodos() + } + + return os.WriteFile(todoPath, []byte(content), 0644) +} + +// getMainPhaseTodos returns TODO.md content for main loop iteration +func (m *MockClaude) getMainPhaseTodos() string { + switch m.iteration { + case 1: + return `# Tasks + +- [ ] Implement feature A +- [ ] Add tests for feature A +- [ ] Update documentation +` + case 2: + return `# Tasks + +- [-] Implement feature A +- [ ] Add tests for feature A +- [ ] Update documentation +` + case 3: + return `# Tasks + +- [x] Implement feature A +- [-] Add tests for feature A +- [ ] Update documentation +` + case 4: + if m.scenario == ScenarioPartial { + // Partial scenario: max iterations hit before completion + return `# Tasks + +- [x] Implement feature A +- [x] Add tests for feature A +- [ ] Update documentation +` + } + return `# Tasks + +- [x] Implement feature A +- [x] Add tests for feature A +- [-] Update documentation +` + default: // 5+ + return `# Tasks + +- [x] Implement feature A +- [x] Add tests for feature A +- [x] Update documentation +` + } +} + +// getCodeReviewTodos returns TODO.md content for code review phase +func (m *MockClaude) getCodeReviewTodos() string { + switch m.iteration { + case 1: + return `# Code Review + +## Issues Found + +- [ ] Fix potential null pointer in handler.go:45 +- [ ] Add error handling for database connection +` + case 2: + return `# Code Review + +## Issues Found + +- [x] Fix potential null pointer in handler.go:45 +- [-] Add error handling for database connection +` + default: + return `# Code Review + +## Issues Found + +- [x] Fix potential null pointer in handler.go:45 +- [x] Add error handling for database connection +` + } +} + +// StreamOutput simulates Claude's JSON stream output +// Returns a channel that yields simulated JSON lines +func (m *MockClaude) StreamOutput() <-chan string { + lines := make(chan string, 10) + go func() { + defer close(lines) + + // Emit simulated output based on phase + switch m.phase { + case "main": + lines <- fmt.Sprintf(`{"type":"assistant","message":{"content":[{"type":"text","text":"[Test Mode] Working on iteration %d..."}]}}`, m.iteration) + case "code_review": + lines <- fmt.Sprintf(`{"type":"assistant","message":{"content":[{"type":"text","text":"[Test Mode] Code review iteration %d..."}]}}`, m.iteration) + } + + lines <- `{"type":"result","subtype":"success","result":"Test iteration complete"}` + }() + return lines +} + +// AllTodosComplete returns true if all todos should be considered complete +func (m *MockClaude) AllTodosComplete() bool { + if m.phase == "main" { + if m.scenario == ScenarioPartial { + return false // Partial never completes + } + return m.iteration >= 5 + } + if m.phase == "code_review" { + return m.iteration >= 3 + } + return false +} diff --git a/internal/testmode/mock_claude_test.go b/internal/testmode/mock_claude_test.go new file mode 100644 index 0000000..bc1986e --- /dev/null +++ b/internal/testmode/mock_claude_test.go @@ -0,0 +1,235 @@ +package testmode + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNewMockClaude(t *testing.T) { + mock := NewMockClaude("success", "/tmp/test-agent") + + if mock.scenario != ScenarioSuccess { + t.Errorf("expected scenario success, got %s", mock.scenario) + } + if mock.agentDir != "/tmp/test-agent" { + t.Errorf("expected agentDir /tmp/test-agent, got %s", mock.agentDir) + } + if mock.phase != "main" { + t.Errorf("expected phase main, got %s", mock.phase) + } + if mock.iteration != 0 { + t.Errorf("expected iteration 0, got %d", mock.iteration) + } +} + +func TestMockClaude_SuccessScenario(t *testing.T) { + tmpDir := t.TempDir() + mock := NewMockClaude("success", tmpDir) + + ctx := context.Background() + + // Run through all iterations + for i := 1; i <= 5; i++ { + exitCode, err := mock.RunIteration(ctx) + if err != nil { + t.Errorf("Iteration %d failed: %v", i, err) + } + if exitCode != 0 { + t.Errorf("Iteration %d exit code = %d, want 0", i, exitCode) + } + + // Verify TODO.md was created + todoPath := filepath.Join(tmpDir, "TODO.md") + if _, err := os.Stat(todoPath); os.IsNotExist(err) { + t.Errorf("TODO.md not created after iteration %d", i) + } + + // Verify iteration counter + if mock.GetIteration() != i { + t.Errorf("expected iteration %d, got %d", i, mock.GetIteration()) + } + } + + // Verify all todos complete + if !mock.AllTodosComplete() { + t.Error("expected AllTodosComplete to return true after 5 iterations") + } +} + +func TestMockClaude_ErrorScenario(t *testing.T) { + tmpDir := t.TempDir() + mock := NewMockClaude("error", tmpDir) + + ctx := context.Background() + + // Iteration 3 should return an error + for i := 1; i <= 3; i++ { + exitCode, err := mock.RunIteration(ctx) + if i == 3 { + if err == nil { + t.Error("Iteration 3 should return error") + } + if exitCode != 1 { + t.Errorf("Iteration 3 exit code = %d, want 1", exitCode) + } + } else { + if err != nil { + t.Errorf("Iteration %d should not error: %v", i, err) + } + } + } +} + +func TestMockClaude_PartialScenario(t *testing.T) { + tmpDir := t.TempDir() + mock := NewMockClaude("partial", tmpDir) + + ctx := context.Background() + + // Run through iterations + for i := 1; i <= 5; i++ { + exitCode, err := mock.RunIteration(ctx) + if err != nil { + t.Errorf("Iteration %d failed: %v", i, err) + } + if exitCode != 0 { + t.Errorf("Iteration %d exit code = %d, want 0", i, exitCode) + } + } + + // Partial scenario should never complete all todos + if mock.AllTodosComplete() { + t.Error("partial scenario should not complete all todos") + } +} + +func TestMockClaude_CodeReviewPhase(t *testing.T) { + tmpDir := t.TempDir() + mock := NewMockClaude("success", tmpDir) + mock.SetPhase("code_review") + + // Verify phase was set and iteration reset + if mock.phase != "code_review" { + t.Errorf("expected phase code_review, got %s", mock.phase) + } + if mock.GetIteration() != 0 { + t.Errorf("expected iteration to reset to 0, got %d", mock.GetIteration()) + } + + ctx := context.Background() + + // Run code review iterations + for i := 1; i <= 3; i++ { + exitCode, err := mock.RunIteration(ctx) + if err != nil { + t.Errorf("Code review iteration %d failed: %v", i, err) + } + if exitCode != 0 { + t.Errorf("Code review iteration %d exit code = %d, want 0", i, exitCode) + } + + // Verify TODO.md was created + todoPath := filepath.Join(tmpDir, "TODO.md") + content, err := os.ReadFile(todoPath) + if err != nil { + t.Errorf("Failed to read TODO.md: %v", err) + } + + // Verify content contains code review issues + if !strings.Contains(string(content), "Code Review") { + t.Errorf("TODO.md should contain 'Code Review' header") + } + } + + // Verify all code review todos complete + if !mock.AllTodosComplete() { + t.Error("expected AllTodosComplete to return true after 3 code review iterations") + } +} + +func TestMockClaude_TodoContent(t *testing.T) { + tmpDir := t.TempDir() + mock := NewMockClaude("success", tmpDir) + ctx := context.Background() + + // Iteration 1: All pending + mock.RunIteration(ctx) + content := readTodoFile(t, tmpDir) + if !strings.Contains(content, "- [ ] Implement feature A") { + t.Error("Iteration 1: expected pending todo for 'Implement feature A'") + } + + // Iteration 2: First in progress + mock.RunIteration(ctx) + content = readTodoFile(t, tmpDir) + if !strings.Contains(content, "- [-] Implement feature A") { + t.Error("Iteration 2: expected in-progress todo for 'Implement feature A'") + } + + // Iteration 3: First complete, second in progress + mock.RunIteration(ctx) + content = readTodoFile(t, tmpDir) + if !strings.Contains(content, "- [x] Implement feature A") { + t.Error("Iteration 3: expected completed todo for 'Implement feature A'") + } + if !strings.Contains(content, "- [-] Add tests for feature A") { + t.Error("Iteration 3: expected in-progress todo for 'Add tests for feature A'") + } +} + +func TestMockClaude_StreamOutput(t *testing.T) { + mock := NewMockClaude("success", t.TempDir()) + + // Run one iteration first + mock.RunIteration(context.Background()) + + // Get stream output + lines := mock.StreamOutput() + var collected []string + for line := range lines { + collected = append(collected, line) + } + + if len(collected) != 2 { + t.Errorf("expected 2 output lines, got %d", len(collected)) + } + + // Check first line contains iteration info + if !strings.Contains(collected[0], "iteration") { + t.Error("first line should contain 'iteration'") + } + + // Check second line is result + if !strings.Contains(collected[1], "result") { + t.Error("second line should contain 'result'") + } +} + +func TestMockClaude_ContextCancellation(t *testing.T) { + mock := NewMockClaude("success", t.TempDir()) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + exitCode, err := mock.RunIteration(ctx) + if err == nil { + t.Error("expected error due to cancelled context") + } + if exitCode != -1 { + t.Errorf("expected exit code -1, got %d", exitCode) + } +} + +// Helper function to read TODO.md content +func readTodoFile(t *testing.T, agentDir string) string { + t.Helper() + todoPath := filepath.Join(agentDir, "TODO.md") + content, err := os.ReadFile(todoPath) + if err != nil { + t.Fatalf("Failed to read TODO.md: %v", err) + } + return string(content) +} From a319e62e15bf73cec50140577c8e462ad004978f Mon Sep 17 00:00:00 2001 From: hev Date: Sat, 10 Jan 2026 06:05:41 -0700 Subject: [PATCH 2/7] Document scratchpad_prompt config option in README Add documentation for the scratchpad_prompt configuration option that allows customizing the instructions appended to prompts. Includes the config reference example and explains the {{.AgentDir}} template syntax. Slack: https://hevmindworkspace.slack.com/archives/C0A6L0UFU6R/p1768050250729429 Co-Authored-By: Claude Opus 4.5 --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index fbf66df..c2e5d20 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,9 @@ slack: bot_token: xoxb-... channel: C0123456789 notify_users: U0123,U0456 + +# Custom scratchpad instructions (replaces the default) +scratchpad_prompt: "Use {{.AgentDir}} for notes. Track tasks in {{.AgentDir}}/TODO.md." ``` ### Configuration Precedence @@ -168,6 +171,8 @@ The scratchpad instructions tell Claude to: - Make commits after each file edit - Work on one task at a time +You can customize these instructions with the `scratchpad_prompt` config option. Use `{{.AgentDir}}` as a placeholder for the agent directory path. + ## Observability Ralph can export metrics to OpenTelemetry for monitoring in Grafana. From af6c00cc3f1449364773e48413424c280613202e Mon Sep 17 00:00:00 2001 From: hev Date: Sat, 10 Jan 2026 06:19:03 -0700 Subject: [PATCH 3/7] Enhance default scratchpad prompt with structured workflow Add comprehensive agent workflow instructions including: - Fresh plan review: validate ordering and dependencies - Completed plan review: verify implementation, add improvements - Testing: run before/after changes, no regressions - Artifact management: keep items under .agent/items/, clean up when done - Session hygiene: commit after cleanup, keep prompt.md updated Co-Authored-By: Claude Opus 4.5 --- README.md | 11 ++++++++--- internal/config/config.go | 25 ++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c2e5d20..19a6d51 100644 --- a/README.md +++ b/README.md @@ -167,9 +167,14 @@ Ralph runs Claude Code in a loop with `--dangerously-skip-permissions`. Each ite The scratchpad instructions tell Claude to: - Use the agent directory as a scratchpad -- Track progress in `TODO.md` using checkboxes -- Make commits after each file edit -- Work on one task at a time +- Track progress in `TODO.md` using checkboxes (`- [ ]` pending, `- [-]` in-progress, `- [x]` done) +- Work on one task at a time and focus on minimal context +- Run tests before and after changes (no regressions allowed) +- Keep todo-item artifacts under `.agent/items//` and clean up when done +- Commit after completing and cleaning up each item +- If reviewing a fresh plan, validate ordering and dependencies +- If reviewing a completed plan, verify implementation and add improvement ideas +- Keep `prompt.md` up to date for the next agent iteration You can customize these instructions with the `scratchpad_prompt` config option. Use `{{.AgentDir}}` as a placeholder for the agent directory path. diff --git a/internal/config/config.go b/internal/config/config.go index fa12fb6..83ffab8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -259,7 +259,30 @@ func getEnvOrDefault(key, defaultVal string) string { } // DefaultScratchpadPrompt is the default prompt appended to instructions -const DefaultScratchpadPrompt = "Use the {{.AgentDir}} directory as a scratchpad for your work. Keep track of your current status in {{.AgentDir}}/TODO.md using checkboxes (- [ ] for pending, - [x] for done). Check off items when completed. Only work on a single item at a time and end your session when complete. Make a commit and push your changes after every single file edit." +const DefaultScratchpadPrompt = `Use the {{.AgentDir}} directory as a scratchpad for your work. + +## Task Tracking +- Keep track of your current status in {{.AgentDir}}/TODO.md using checkboxes (- [ ] for pending, - [-] for in-progress, - [x] for done). +- Check off items when completed. Only work on a single item at a time. +- If a bug or new behavior comes up during implementation, add it to the todo list for tracking in the next iteration. + +## Starting a Session +- If reviewing a fresh todo list: Read the whole plan (prompt.md) and determine if the ordering and dependencies make sense. If adjustments are needed, copy the original prompt.md to {{.AgentDir}}/prompt_original.md and revise prompt.md based on your determination. +- If reviewing a completed plan: Test that the implementation is working to the original spec. Add ideas for improvements or bug fixes to the todo list if you find any. + +## Testing +- If the project has tests, always run them before and after making your changes. +- Do not accept any regressions. Always add new tests for new functionality. + +## Artifact Management +- Keep track of todo-item artifacts (scripts, notes, temp files) under {{.AgentDir}}/items//. +- When done with a todo item, clean up scripts, plans, and any temporary assets for that item (remove the folder). + +## Completing Work +- Focus on doing one thing at a time and keeping context to only necessary information. +- When done cleaning up after a todo item, commit your changes. +- Before ending your session, review the plan and ensure any new todo items that came up are added. Make sure prompt.md is up to date and ready for the next agent. +- End your session when complete.` // ScratchpadInstructions returns the instructions appended to prompts // Supports {{.AgentDir}} template substitution in the prompt From a6939c6162f6056dc3aff2a0d8d03a7940f39dc8 Mon Sep 17 00:00:00 2001 From: hev Date: Sat, 10 Jan 2026 06:19:39 -0700 Subject: [PATCH 4/7] Remove obsolete test coverage plan The test coverage plan has been fully implemented - all packages now have test files and tests are passing. The prompt.md file is no longer needed. Slack: https://hevmindworkspace.slack.com/archives/C0A6L0UFU6R/p1768050346701169 Co-Authored-By: Claude Opus 4.5 --- prompt.md | 429 ------------------------------------------------------ 1 file changed, 429 deletions(-) delete mode 100644 prompt.md diff --git a/prompt.md b/prompt.md deleted file mode 100644 index 437ec7d..0000000 --- a/prompt.md +++ /dev/null @@ -1,429 +0,0 @@ -# Test Coverage Plan for Ralph - -## Current State - -The codebase has **zero test coverage**. No `*_test.go` files exist. - -## Testing Strategy - -Use standard Go testing (`testing` package) with table-driven tests. Create interfaces for external dependencies to enable mocking. Focus on unit tests first, then add integration tests for critical paths. - -## Package Testing Priority - -### 1. `internal/todo` - Parser (Low Risk, Easy Start) - -**File to create:** `internal/todo/parser_test.go` - -**Functions to test:** -- `ParseFile()` - File reading and counting -- `ParseItems()` - Full item extraction with text - -**Test cases:** -- Pending items: `- [ ] task` -- Completed items: `- [x] task` and `- [X] task` -- In-progress items: `- [-] task` and `- [~] task` -- Mixed checkbox styles in same file -- Empty file -- File with no checkboxes -- Nested lists (should still count) -- Malformed checkboxes -- File not found error - -**Approach:** Create test fixture files or use inline strings with `strings.NewReader`. - ---- - -### 2. `internal/config` - Configuration (Medium Complexity) - -**File to create:** `internal/config/config_test.go` - -**Functions to test:** -- `DefaultConfig()` - Verify all defaults are sensible -- `LoadFromFile()` - YAML loading and merging -- `FindConfigFiles()` - File discovery logic -- `GetSlackNotifyUsers()` - CSV parsing -- `ScratchpadInstructions()` - Template generation -- `CodeReviewInstructions()` - Template generation - -**Test cases:** -- Default config values are correct -- YAML overwrites only specified fields -- Missing YAML file returns error -- Invalid YAML returns error -- Empty YAML file works (no changes) -- Partial YAML (some fields only) -- Slack user parsing: single user, multiple users, empty string -- Environment variable `RALPH_MODEL` override -- Config file precedence (global vs local) - -**Approach:** Create temp YAML files for file-based tests. Test merging logic with struct comparison. - ---- - -### 3. `internal/claude` - Parser (Critical, Medium Complexity) - -**File to create:** `internal/claude/parser_test.go` - -**Functions to test:** -- `ParseAndPrint()` - Main dispatcher (with captured output) -- `formatTodoWrite()` - Checklist formatting -- `formatEdit()` - Diff-style output -- `formatReadResult()` - Truncation logic -- `formatChecklist()` - Markdown checklist detection -- `stripSystemReminders()` - Regex filtering - -**Test cases:** -- Valid JSON message parsing for each type -- Invalid JSON handling -- System reminder stripping (various formats) -- TodoWrite with pending/completed/in-progress items -- Edit formatting with old/new strings -- Read result truncation at threshold -- Checklist detection (markdown checkboxes) -- Empty content handling -- Very large content truncation - -**Approach:** Create JSON fixture strings. Capture stdout for output verification or refactor to return strings. - ---- - -### 4. `internal/claude` - Client (Requires Mocking) - -**File to create:** `internal/claude/client_test.go` - -**Functions to test:** -- `NewClient()` - Command construction -- `Wait()` - Exit code extraction -- Error message parsing from stderr - -**Test cases:** -- Command flags are correctly set -- Exit code 0 handling -- Non-zero exit code handling -- Process kill behavior -- Timeout handling - -**Approach:** Create interface for command execution to mock `exec.Cmd`. Alternatively, test command construction without execution. - ---- - -### 5. `internal/git` - Tracker and PR (Requires Mocking) - -**Files to create:** -- `internal/git/tracker_test.go` -- `internal/git/pr_test.go` - -**Functions to test:** -- `NewTracker()` - Initial baseline -- `CommitsDelta()` - Delta calculation -- `UpdateBaseline()` - Baseline reset -- `CreatePR()` - PR creation -- `GetCurrentBranch()` - Branch detection -- `GetDefaultBranch()` - main/master detection -- `IsBranchPushed()` - Remote check - -**Test cases:** -- Commit delta: 0 commits, 5 commits, negative (shouldn't happen) -- Branch detection: main, master, custom default -- PR creation success -- PR creation failure (gh not installed, auth error) -- Already pushed vs needs push - -**Approach:** Create command executor interface to mock git/gh CLI calls. - ---- - -### 6. `internal/worktree` - Manager (Requires Mocking) - -**File to create:** `internal/worktree/worktree_test.go` - -**Functions to test:** -- `Create()` - Worktree creation -- `Remove()` - Cleanup -- `generateBranchName()` - Name generation -- `sanitizeBranchName()` - Path sanitization -- `branchExists()` - Branch check - -**Test cases:** -- Branch name format: `ralph/YYYYMMDD-HHMMSS` -- Custom branch name provided -- Branch sanitization: `/` to `-` -- Branch already exists handling -- Worktree path construction -- Remove with force flag -- Directory switching during cleanup - -**Approach:** Mock git commands. Test name generation and sanitization without mocking. - ---- - -### 7. `internal/slack` - Messages (Pure Functions, Easy) - -**File to create:** `internal/slack/messages_test.go` - -**Functions to test:** -- `FormatSessionStart()` -- `FormatSessionEnd()` -- `FormatTodoStarted()` -- `FormatTodoCompleted()` -- `FormatCodeReviewStarted()` -- `FormatCodeReviewComplete()` -- `FormatCleanupStarted()` -- `FormatCleanupComplete()` -- `FormatPRCreated()` -- `formatDuration()` -- `truncateSessionID()` - -**Test cases:** -- Duration formatting: 0s, 30s, 90s, 3600s, 7200s -- Session ID truncation: full UUID to 8 chars -- All message types contain required fields -- User mention formatting -- GitHub URL inclusion -- Completion rate calculation (0%, 50%, 100%) - -**Approach:** Pure function testing with struct comparison. - ---- - -### 8. `internal/slack` - Client (HTTP Mocking) - -**File to create:** `internal/slack/client_test.go` - -**Functions to test:** -- `PostWebhook()` - Webhook POST -- `PostMessage()` - API POST -- `PostWithRetry()` - Retry logic -- `IsConfigured()` - Configuration check - -**Test cases:** -- Successful webhook post -- Webhook error (4xx, 5xx) -- Successful API post with thread_ts -- API error handling -- Retry on 5xx (1s, 2s, 4s backoff) -- No retry on 4xx -- Context cancellation during retry -- Not configured returns early - -**Approach:** Use `httptest.Server` for HTTP mocking. - ---- - -### 9. `internal/slack` - Notifier (Integration) - -**File to create:** `internal/slack/notifier_test.go` - -**Functions to test:** -- `SessionStart()` / `SessionEnd()` -- `TodoStarted()` / `TodoCompleted()` -- Message routing (webhook vs API) -- Threading behavior - -**Test cases:** -- Webhook mode: messages go to webhook -- Bot mode: messages go to API with threading -- Disabled mode: no errors, no calls -- Thread timestamp propagation - -**Approach:** Mock the Client interface. - ---- - -### 10. `internal/metrics` - Tracker (Medium Complexity) - -**File to create:** `internal/metrics/tracker_test.go` - -**Functions to test:** -- `GetTodoCounts()` - Count aggregation -- `GetTodoItems()` - Item list -- `GetNewlyCompletedTodos()` - Delta detection -- `GetNewlyInProgressTodos()` - Delta detection -- `UpdatePreviousTodos()` - Snapshot update - -**Test cases:** -- No previous todos, all new -- Some completed since last check -- Some started since last check -- No changes between checks -- Empty todo file - -**Approach:** Create todo fixture files or mock file reading. - ---- - -### 11. `cmd/ralph` - Runner (Integration, Most Complex) - -**File to create:** `cmd/ralph/runner_test.go` - -**Functions to test:** -- `Run()` - Main loop (with extensive mocking) -- `runCodeReviewPhase()` - Review loop -- `runCleanupPhase()` - File cleanup -- `runPRPhase()` - PR creation -- `generatePRTitle()` - Title generation -- `generatePRBody()` - Body generation -- `copyFile()` - File copy helper -- `cleanupWorktree()` - Cleanup helper -- `printSummary()` - Summary output - -**Test cases:** -- Single iteration success -- Max iterations reached -- Max time reached -- User completion (exit code 0) -- Signal handling (SIGINT, SIGTERM) -- Worktree mode vs in-place mode -- Code review phase sequencing -- Cleanup phase with patterns -- PR phase success/failure -- PR title/body generation with various states - -**Approach:** Create comprehensive mocks for Claude, git, Slack, metrics. Test in isolation first. - ---- - -## Interfaces to Create - -Create these interfaces to enable mocking: - -### `internal/claude/interfaces.go` -```go -type Runner interface { - Start() error - Wait() (int, error) - StreamOutput() <-chan string - Kill() error -} -``` - -### `internal/git/interfaces.go` -```go -type CommandExecutor interface { - Run(name string, args ...string) ([]byte, error) -} - -type PRCreator interface { - CreatePR(cfg PRConfig) (*PRResult, error) -} - -type CommitTracker interface { - CommitsDelta() (int, error) - UpdateBaseline() error -} -``` - -### `internal/slack/interfaces.go` -```go -type Messenger interface { - PostMessage(ctx context.Context, req *ChatPostMessageRequest) (*ChatPostMessageResponse, error) - PostWebhook(ctx context.Context, msg *WebhookMessage) error - IsConfigured() bool -} -``` - -### `internal/metrics/interfaces.go` -```go -type Collector interface { - RecordIterationComplete(ctx context.Context, duration time.Duration, exitReason string) - RecordCommits(ctx context.Context, count int) - RecordError(ctx context.Context, errType string) - UpdateTodoCounts(pending, completed int) - Shutdown(ctx context.Context) error -} -``` - ---- - -## Test Fixtures - -Create `testdata/` directories in relevant packages: - -``` -internal/todo/testdata/ -├── empty.md -├── all_pending.md -├── all_completed.md -├── mixed.md -└── nested.md - -internal/config/testdata/ -├── minimal.yaml -├── full.yaml -├── invalid.yaml -└── partial.yaml - -internal/claude/testdata/ -├── user_message.json -├── assistant_message.json -├── todo_write.json -├── edit_result.json -└── system_reminder.json -``` - ---- - -## Implementation Order - -1. **`internal/todo`** - Start here. Pure parsing, no dependencies. -2. **`internal/slack/messages`** - Pure formatting functions. -3. **`internal/config`** - File I/O but straightforward. -4. **`internal/claude/parser`** - JSON parsing, may need output capture. -5. **Create interfaces** - Before tackling components with external deps. -6. **`internal/git`** - With command executor mock. -7. **`internal/worktree`** - With command executor mock. -8. **`internal/slack/client`** - With HTTP mocking. -9. **`internal/metrics`** - With mocked dependencies. -10. **`internal/claude/client`** - With process mocking. -11. **`cmd/ralph/runner`** - Full integration with all mocks. - ---- - -## Coverage Goals - -| Package | Target Coverage | -|---------|-----------------| -| `internal/todo` | 90%+ | -| `internal/config` | 85%+ | -| `internal/claude` | 80%+ | -| `internal/git` | 75%+ | -| `internal/worktree` | 75%+ | -| `internal/slack` | 80%+ | -| `internal/metrics` | 75%+ | -| `cmd/ralph` | 70%+ | - ---- - -## Testing Commands - -```bash -# Run all tests -go test ./... - -# Run with coverage -go test -cover ./... - -# Generate coverage report -go test -coverprofile=coverage.out ./... -go tool cover -html=coverage.out -o coverage.html - -# Run specific package -go test ./internal/todo/... - -# Run with verbose output -go test -v ./... - -# Run specific test -go test -run TestParseFile ./internal/todo/... -``` - ---- - -## Notes - -- Use `t.Parallel()` for tests that don't share state -- Use `t.Helper()` in test helper functions -- Prefer table-driven tests for multiple cases -- Use `testify/assert` or `testify/require` if desired (not currently a dependency) -- Keep test files in the same package for access to unexported functions -- Consider `go-cmp` for struct comparisons From da6ec43c27961370e9aca4db30374c91bb39a5c6 Mon Sep 17 00:00:00 2001 From: hev Date: Sat, 10 Jan 2026 06:29:25 -0700 Subject: [PATCH 5/7] Fix .gitignore to not exclude internal/ralph package The pattern `ralph` was too broad and matched both the binary and the internal/ralph/ directory, causing CI failures. Co-Authored-By: Claude Opus 4.5 --- .gitignore | 2 +- internal/ralph/sound.go | 242 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 internal/ralph/sound.go diff --git a/.gitignore b/.gitignore index 26dabac..b99f1cc 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ # Build output build/ dist/ -ralph +/ralph # Go *.exe diff --git a/internal/ralph/sound.go b/internal/ralph/sound.go new file mode 100644 index 0000000..24b7fcd --- /dev/null +++ b/internal/ralph/sound.go @@ -0,0 +1,242 @@ +package ralph + +import ( + "fmt" + "io" + "math/rand" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/hev/ralph/internal/config" +) + +// HTTP client with timeout +var httpClient = &http.Client{ + Timeout: 10 * time.Second, +} + +const ( + soundsCacheFile = "ralph_sounds.txt" +) + +// SoundPlayer handles fetching and playing Ralph Wiggum audio clips +type SoundPlayer struct { + config config.SoundConfig + sounds []string +} + +// NewSoundPlayer creates a new SoundPlayer with the given config +func NewSoundPlayer(cfg config.SoundConfig) *SoundPlayer { + return &SoundPlayer{ + config: cfg, + } +} + +// Play fetches a random Ralph quote and plays it asynchronously +func (p *SoundPlayer) Play() error { + if !p.config.Enabled || p.config.Mute { + return nil + } + + // Load sounds if not already loaded (do this synchronously on first call) + if len(p.sounds) == 0 { + if err := p.loadSounds(); err != nil { + return fmt.Errorf("failed to load sounds: %w", err) + } + } + + if len(p.sounds) == 0 { + return fmt.Errorf("no sounds available") + } + + // Pick a random sound + rand.Seed(time.Now().UnixNano()) + soundURL := p.sounds[rand.Intn(len(p.sounds))] + + // Play asynchronously so we don't block the loop + go func() { + tmpFile, err := p.downloadSound(soundURL) + if err != nil { + return + } + defer os.Remove(tmpFile) + p.playSound(tmpFile) + }() + + return nil +} + +// loadSounds loads the sound URLs from cache or fetches them +func (p *SoundPlayer) loadSounds() error { + // Ensure cache directory exists + if err := os.MkdirAll(p.config.CacheDir, 0755); err != nil { + return err + } + + cacheFile := filepath.Join(p.config.CacheDir, soundsCacheFile) + + // Try to load from cache first + if data, err := os.ReadFile(cacheFile); err == nil { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + p.sounds = append(p.sounds, line) + } + } + if len(p.sounds) > 0 { + return nil + } + } + + // Fetch from the sound page + if err := p.fetchSounds(); err != nil { + return err + } + + // Cache the sounds + if len(p.sounds) > 0 { + data := strings.Join(p.sounds, "\n") + os.WriteFile(cacheFile, []byte(data), 0644) + } + + return nil +} + +// fetchSounds fetches the sound page and extracts MP3 URLs +func (p *SoundPlayer) fetchSounds() error { + resp, err := httpClient.Get(p.config.PageURL) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to fetch sound page: %s", resp.Status) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + // Parse base URL for resolving relative paths + baseURL, err := url.Parse(p.config.PageURL) + if err != nil { + return err + } + + // Extract MP3 links using regex + // Look for href="...mp3" or src="...mp3" patterns + mp3Pattern := regexp.MustCompile(`(?:href|src)=["']([^"']*\.mp3)["']`) + matches := mp3Pattern.FindAllStringSubmatch(string(body), -1) + + for _, match := range matches { + if len(match) > 1 { + mp3URL := match[1] + // Resolve relative URLs + if !strings.HasPrefix(mp3URL, "http://") && !strings.HasPrefix(mp3URL, "https://") { + ref, err := url.Parse(mp3URL) + if err != nil { + continue + } + mp3URL = baseURL.ResolveReference(ref).String() + } + p.sounds = append(p.sounds, mp3URL) + } + } + + return nil +} + +// downloadSound downloads a sound file to a temporary location +func (p *SoundPlayer) downloadSound(soundURL string) (string, error) { + resp, err := httpClient.Get(soundURL) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to download sound: %s", resp.Status) + } + + // Create temp file + tmpFile, err := os.CreateTemp("", "ralph_*.mp3") + if err != nil { + return "", err + } + defer tmpFile.Close() + + if _, err := io.Copy(tmpFile, resp.Body); err != nil { + os.Remove(tmpFile.Name()) + return "", err + } + + return tmpFile.Name(), nil +} + +// playSound plays the sound file using an available player +func (p *SoundPlayer) playSound(filePath string) error { + // List of players to try in order of preference + // afplay is macOS native, ffplay is cross-platform, mpg123/mpg321 are common on Linux + players := []struct { + name string + args []string + }{ + {"afplay", []string{filePath}}, + {"ffplay", []string{"-nodisp", "-autoexit", "-loglevel", "quiet", filePath}}, + {"mpg123", []string{"-q", filePath}}, + {"mpg321", []string{"-q", filePath}}, + } + + // If a preferred player is set, try it first + if p.config.Player != "" { + for i, player := range players { + if player.name == p.config.Player { + // Move preferred player to front + players = append([]struct { + name string + args []string + }{player}, append(players[:i], players[i+1:]...)...) + break + } + } + } + + // Try each player until one works + var lastErr error + for _, player := range players { + if path, err := exec.LookPath(player.name); err == nil { + cmd := exec.Command(path, player.args...) + cmd.Stdout = nil + cmd.Stderr = nil + if err := cmd.Run(); err != nil { + lastErr = err + continue + } + return nil + } + } + + if lastErr != nil { + return fmt.Errorf("all players failed, last error: %w", lastErr) + } + return fmt.Errorf("no audio player found (tried: afplay, ffplay, mpg123, mpg321)") +} + +// ClearCache removes the cached sound URLs +func (p *SoundPlayer) ClearCache() error { + cacheFile := filepath.Join(p.config.CacheDir, soundsCacheFile) + if err := os.Remove(cacheFile); err != nil && !os.IsNotExist(err) { + return err + } + p.sounds = nil + return nil +} From 6a69eeec841cc806d0b25202df0bcd7e6172c080 Mon Sep 17 00:00:00 2001 From: hev Date: Sat, 10 Jan 2026 06:31:32 -0700 Subject: [PATCH 6/7] Remove lint job from CI golangci-lint doesn't support Go 1.25 yet. Co-Authored-By: Claude Opus 4.5 --- .github/workflows/ci.yml | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bb074b..ea1d672 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,28 +46,10 @@ jobs: path: coverage.out retention-days: 7 - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: 'go.mod' - cache: true - - - name: Run golangci-lint - uses: golangci/golangci-lint-action@v6 - with: - version: latest - build: name: Build runs-on: ubuntu-latest - needs: [test, lint] + needs: [test] strategy: matrix: goos: [linux, darwin, windows] From cbf5db0d61b4f7e6ef002d24cd4aca69fd0b2ff2 Mon Sep 17 00:00:00 2001 From: hev Date: Sat, 10 Jan 2026 06:37:13 -0700 Subject: [PATCH 7/7] Fix race condition in metrics Collector Use atomic.Int64 for currentPending and currentComplete fields to prevent data races when UpdateTodoCounts is called concurrently with the observable gauge callbacks. Co-Authored-By: Claude Opus 4.5 --- internal/metrics/collector.go | 13 +++++----- internal/metrics/collector_test.go | 40 +++++++++++++++--------------- 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/internal/metrics/collector.go b/internal/metrics/collector.go index c786535..7a39618 100644 --- a/internal/metrics/collector.go +++ b/internal/metrics/collector.go @@ -2,6 +2,7 @@ package metrics import ( "context" + "sync/atomic" "time" "go.opentelemetry.io/otel" @@ -41,8 +42,8 @@ type Collector struct { // State for gauges startTime time.Time - currentPending int64 - currentComplete int64 + currentPending atomic.Int64 + currentComplete atomic.Int64 } // CollectorConfig holds configuration for the metrics collector @@ -179,7 +180,7 @@ func (c *Collector) initMetrics(prefix string) error { prefix+"_todos_pending", metric.WithDescription("Current pending todo items"), metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error { - o.Observe(c.currentPending, metric.WithAttributes(c.projectAttr, c.sessionIDAttr)) + o.Observe(c.currentPending.Load(), metric.WithAttributes(c.projectAttr, c.sessionIDAttr)) return nil }), ) @@ -191,7 +192,7 @@ func (c *Collector) initMetrics(prefix string) error { prefix+"_todos_completed", metric.WithDescription("Current completed todo items"), metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error { - o.Observe(c.currentComplete, metric.WithAttributes(c.projectAttr, c.sessionIDAttr)) + o.Observe(c.currentComplete.Load(), metric.WithAttributes(c.projectAttr, c.sessionIDAttr)) return nil }), ) @@ -235,8 +236,8 @@ func (c *Collector) RecordError(ctx context.Context, errorType string) { // UpdateTodoCounts updates the todo gauge values func (c *Collector) UpdateTodoCounts(pending, completed int) { - c.currentPending = int64(pending) - c.currentComplete = int64(completed) + c.currentPending.Store(int64(pending)) + c.currentComplete.Store(int64(completed)) } // SessionStart marks the session as active diff --git a/internal/metrics/collector_test.go b/internal/metrics/collector_test.go index cdbf05b..1bac488 100644 --- a/internal/metrics/collector_test.go +++ b/internal/metrics/collector_test.go @@ -69,31 +69,31 @@ func TestCollector_UpdateTodoCounts(t *testing.T) { collector := &Collector{enabled: false} // Initial values should be 0 - if collector.currentPending != 0 { - t.Errorf("Initial currentPending = %d, want 0", collector.currentPending) + if collector.currentPending.Load() != 0 { + t.Errorf("Initial currentPending = %d, want 0", collector.currentPending.Load()) } - if collector.currentComplete != 0 { - t.Errorf("Initial currentComplete = %d, want 0", collector.currentComplete) + if collector.currentComplete.Load() != 0 { + t.Errorf("Initial currentComplete = %d, want 0", collector.currentComplete.Load()) } // Update counts collector.UpdateTodoCounts(5, 3) - if collector.currentPending != 5 { - t.Errorf("After update currentPending = %d, want 5", collector.currentPending) + if collector.currentPending.Load() != 5 { + t.Errorf("After update currentPending = %d, want 5", collector.currentPending.Load()) } - if collector.currentComplete != 3 { - t.Errorf("After update currentComplete = %d, want 3", collector.currentComplete) + if collector.currentComplete.Load() != 3 { + t.Errorf("After update currentComplete = %d, want 3", collector.currentComplete.Load()) } // Update again collector.UpdateTodoCounts(10, 15) - if collector.currentPending != 10 { - t.Errorf("After second update currentPending = %d, want 10", collector.currentPending) + if collector.currentPending.Load() != 10 { + t.Errorf("After second update currentPending = %d, want 10", collector.currentPending.Load()) } - if collector.currentComplete != 15 { - t.Errorf("After second update currentComplete = %d, want 15", collector.currentComplete) + if collector.currentComplete.Load() != 15 { + t.Errorf("After second update currentComplete = %d, want 15", collector.currentComplete.Load()) } } @@ -290,11 +290,11 @@ func TestCollector_Attributes(t *testing.T) { // Access currentPending and currentComplete (these are public through UpdateTodoCounts) collector.UpdateTodoCounts(10, 5) - if collector.currentPending != 10 { - t.Errorf("currentPending = %d, want 10", collector.currentPending) + if collector.currentPending.Load() != 10 { + t.Errorf("currentPending = %d, want 10", collector.currentPending.Load()) } - if collector.currentComplete != 5 { - t.Errorf("currentComplete = %d, want 5", collector.currentComplete) + if collector.currentComplete.Load() != 5 { + t.Errorf("currentComplete = %d, want 5", collector.currentComplete.Load()) } } @@ -313,11 +313,11 @@ func TestCollector_MultipleUpdates(t *testing.T) { } // Final state should reflect last update - if collector.currentPending != 9 { - t.Errorf("After 10 updates, currentPending = %d, want 9", collector.currentPending) + if collector.currentPending.Load() != 9 { + t.Errorf("After 10 updates, currentPending = %d, want 9", collector.currentPending.Load()) } - if collector.currentComplete != 18 { - t.Errorf("After 10 updates, currentComplete = %d, want 18", collector.currentComplete) + if collector.currentComplete.Load() != 18 { + t.Errorf("After 10 updates, currentComplete = %d, want 18", collector.currentComplete.Load()) } }