From d8eaade99c28b5b760f67acba6582a23cfb81a63 Mon Sep 17 00:00:00 2001 From: Dzmitry Misiuk Date: Fri, 26 Sep 2025 03:33:44 +0200 Subject: [PATCH] Decouple visual tests from robotgo dependency --- .gitignore | 2 + README.md | 27 +++- cmd/acousticalc/main_integration_test.go | 168 ----------------------- pkg/calculator/calculator_visual_test.go | 2 + tests/cross_platform/platform.go | 35 +++++ tests/e2e/acousticalc_workflows_test.go | 168 +++++++++++++++++++++++ tests/e2e/harness.go | 128 +++++++++++++++++ tests/recording/recorder.go | 140 +++++++++++++++++++ tests/reporting/reporter.go | 65 +++++++++ tests/scripts/Makefile | 9 +- tests/visual/robotgo_engine.go | 41 ++++++ tests/visual/robotgo_engine_stub.go | 26 ++++ tests/visual/visual_utils.go | 96 +++++-------- 13 files changed, 671 insertions(+), 236 deletions(-) delete mode 100644 cmd/acousticalc/main_integration_test.go create mode 100644 tests/cross_platform/platform.go create mode 100644 tests/e2e/acousticalc_workflows_test.go create mode 100644 tests/e2e/harness.go create mode 100644 tests/recording/recorder.go create mode 100644 tests/reporting/reporter.go create mode 100644 tests/visual/robotgo_engine.go create mode 100644 tests/visual/robotgo_engine_stub.go diff --git a/.gitignore b/.gitignore index 2540fcf..27560e8 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,5 @@ go.work.sum # Artifacts artifacts/ +tests/recording/artifacts/ +tests/reporting/artifacts/ diff --git a/README.md b/README.md index be713fa..84e9a94 100644 --- a/README.md +++ b/README.md @@ -117,14 +117,29 @@ go build -o acousticalc ./cmd/acousticalc ### Running Tests ```bash -# Run all tests -go test ./... +# Run fast unit and integration tests +go test ./tests/unit ./tests/integration -# Run tests with coverage -go test -cover ./... +# Run cross-platform end-to-end CLI tests with terminal recordings +go test ./tests/e2e + +# View generated recordings and aggregated reports +ls tests/recording/artifacts +cat tests/reporting/artifacts/e2e_report.json + +The Story 0.2.3 infrastructure stores per-run asciinema recordings under +`tests/recording/artifacts/` and persists consolidated cross-platform test +reports to `tests/reporting/artifacts/e2e_report.json` so GitHub Actions can +publish demo-ready evidence alongside the test matrix results. + +# Enable visual evidence tests (requires CGO + desktop dependencies) +go test -tags visualtests ./pkg/... + +# Generate coverage for unit tests +go test -cover ./tests/unit -# Run tests with coverage report -go test -coverprofile=coverage.out && go tool cover -html=coverage.out +# Generate coverage report for unit tests +go test -coverprofile=coverage.out ./tests/unit && go tool cover -html=coverage.out ``` ## 📋 Roadmap diff --git a/cmd/acousticalc/main_integration_test.go b/cmd/acousticalc/main_integration_test.go deleted file mode 100644 index 2f9cc9f..0000000 --- a/cmd/acousticalc/main_integration_test.go +++ /dev/null @@ -1,168 +0,0 @@ -package main - -import ( - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "testing" -) - -// getExecutablePath returns the path to the acousticalc executable -func getExecutablePath() (string, error) { - // Get the current file's directory - _, filename, _, ok := runtime.Caller(0) - if !ok { - return "", os.ErrNotExist - } - - // Build the path to the executable - // The executable is in the same directory as the test file - dir := filepath.Dir(filename) - executableName := "acousticalc" - if runtime.GOOS == "windows" { - executableName += ".exe" - } - return filepath.Join(dir, executableName), nil -} - -// TestCLIValidExpressions tests the CLI with valid mathematical expressions -func TestCLIValidExpressions(t *testing.T) { - executable, err := getExecutablePath() - if err != nil { - t.Skipf("Could not find executable: %v", err) - } - - testCases := []struct { - name string - expression string - expected string - }{ - { - name: "Basic addition", - expression: "2 + 3", - expected: "Result: 5", - }, - { - name: "Operator precedence", - expression: "2 + 3 * 4", - expected: "Result: 14", - }, - { - name: "Parentheses", - expression: "(2 + 3) * 4", - expected: "Result: 20", - }, - { - name: "Decimal numbers", - expression: "3.5 + 2.1", - expected: "Result: 5.6", - }, - { - name: "Complex expression", - expression: "2 * (3 + 4) - 5 / 2", - expected: "Result: 11.5", - }, - { - name: "Negative numbers", - expression: "-5 + 3", - expected: "Result: -2", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - cmd := exec.Command(executable, tc.expression) - output, err := cmd.CombinedOutput() - - if err != nil { - t.Fatalf("CLI command failed: %v", err) - } - - actual := strings.TrimSpace(string(output)) - if actual != tc.expected { - t.Errorf("Expected %q, got %q", tc.expected, actual) - } - }) - } -} - -// TestCLIInvalidExpressions tests the CLI with invalid mathematical expressions -func TestCLIInvalidExpressions(t *testing.T) { - executable, err := getExecutablePath() - if err != nil { - t.Skipf("Could not find executable: %v", err) - } - - testCases := []struct { - name string - expression string - expected string - }{ - { - name: "Division by zero", - expression: "10 / 0", - expected: "Error: division by zero", - }, - { - name: "Invalid syntax", - expression: "2 +", - expected: "Error:", - }, - { - name: "Invalid character", - expression: "2 + a", - expected: "Error:", - }, - { - name: "Empty expression", - expression: "", - expected: "Error: empty expression", - }, - { - name: "Mismatched parentheses", - expression: "(2 + 3", - expected: "Error:", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - cmd := exec.Command(executable, tc.expression) - output, err := cmd.CombinedOutput() - - // We expect the command to fail for invalid expressions - if err == nil { - t.Errorf("Expected command to fail for invalid expression %q", tc.expression) - } - - actual := strings.TrimSpace(string(output)) - if !strings.Contains(actual, tc.expected) { - t.Errorf("Expected output to contain %q, got %q", tc.expected, actual) - } - }) - } -} - -// TestCLINoArguments tests the CLI when no arguments are provided -func TestCLINoArguments(t *testing.T) { - executable, err := getExecutablePath() - if err != nil { - t.Skipf("Could not find executable: %v", err) - } - - cmd := exec.Command(executable) - output, err := cmd.CombinedOutput() - - // We expect the command to fail when no arguments are provided - if err == nil { - t.Error("Expected command to fail when no arguments are provided") - } - - actual := strings.TrimSpace(string(output)) - expectedUsage := "Usage: acousticalc " - if !strings.Contains(actual, expectedUsage) { - t.Errorf("Expected usage message, got: %s", actual) - } -} diff --git a/pkg/calculator/calculator_visual_test.go b/pkg/calculator/calculator_visual_test.go index 635c96d..649d446 100644 --- a/pkg/calculator/calculator_visual_test.go +++ b/pkg/calculator/calculator_visual_test.go @@ -1,3 +1,5 @@ +//go:build visualtests + package calculator import ( diff --git a/tests/cross_platform/platform.go b/tests/cross_platform/platform.go new file mode 100644 index 0000000..dedcf90 --- /dev/null +++ b/tests/cross_platform/platform.go @@ -0,0 +1,35 @@ +package cross_platform + +import ( + "runtime" + "strings" +) + +// Platform returns the canonical name of the platform the tests are running on. +// +// The Story 0.2.3 acceptance criteria requires cross-platform validation and +// reporting. Centralising the platform string ensures consistent labelling +// across the E2E framework, recorder, and reporters. +func Platform() string { + return runtime.GOOS +} + +// NormalizeNewlines converts Windows style CRLF sequences into the newline +// representation used on Unix-like systems. This allows assertions written in +// a platform-agnostic fashion to succeed regardless of the host operating +// system the GitHub Actions job is running on. +func NormalizeNewlines(s string) string { + if runtime.GOOS != "windows" { + return s + } + return strings.ReplaceAll(s, "\r\n", "\n") +} + +// PathWithExecutableSuffix appends the Windows executable suffix when running +// on Windows. Other platforms return the path unchanged. +func PathWithExecutableSuffix(path string) string { + if runtime.GOOS == "windows" { + return path + ".exe" + } + return path +} diff --git a/tests/e2e/acousticalc_workflows_test.go b/tests/e2e/acousticalc_workflows_test.go new file mode 100644 index 0000000..98684f0 --- /dev/null +++ b/tests/e2e/acousticalc_workflows_test.go @@ -0,0 +1,168 @@ +package e2e + +import ( + "path/filepath" + "strings" + "testing" + "time" + + "github.com/dmisiuk/acousticalc/tests/cross_platform" + "github.com/dmisiuk/acousticalc/tests/recording" + "github.com/dmisiuk/acousticalc/tests/reporting" +) + +type workflow struct { + name string + args []string + expectedExitCode int + expectStdoutSubstrs []string + expectStderrSubstrs []string + platformOverrides map[string]platformExpectations +} + +type platformExpectations struct { + expectedExitCode *int + expectStdoutSubstrs []string + expectStderrSubstrs []string +} + +func TestAcoustiCalcWorkflows(t *testing.T) { + runner, err := NewCLIRunner(15 * time.Second) + if err != nil { + t.Fatalf("failed to create CLI runner: %v", err) + } + + root, err := repositoryRoot() + if err != nil { + t.Fatalf("failed to resolve repository root: %v", err) + } + + recorder, err := recording.NewSessionRecorder(filepath.Join(root, "tests", "recording", "artifacts")) + if err != nil { + t.Fatalf("failed to create session recorder: %v", err) + } + + reporter := reporting.NewReporter(filepath.Join(root, "tests", "reporting", "artifacts", "e2e_report.json")) + + workflows := []workflow{ + { + name: "basic-evaluation", + args: []string{"2+3*4"}, + expectedExitCode: 0, + expectStdoutSubstrs: []string{ + "Result: 14", + }, + }, + { + name: "whitespace-evaluation", + args: []string{"2 + 3 * 4"}, + expectedExitCode: 0, + expectStdoutSubstrs: []string{"Result: 14"}, + }, + { + name: "invalid-expression", + args: []string{"2 + (3"}, + expectedExitCode: 1, + expectStdoutSubstrs: []string{"Error:"}, + }, + { + name: "no-arguments", + args: []string{}, + expectedExitCode: 1, + expectStdoutSubstrs: []string{"Usage: acousticalc"}, + platformOverrides: map[string]platformExpectations{ + "windows": { + expectStdoutSubstrs: []string{"Usage: acousticalc"}, + }, + }, + }, + } + + platform := cross_platform.Platform() + + for _, wf := range workflows { + wf := wf + t.Run(wf.name, func(t *testing.T) { + t.Parallel() + + status := "failed" + var runResult *RunResult + var recordingPath string + + t.Cleanup(func() { + if runResult != nil { + rel := recordingPath + if rel == "" { + path, err := recorder.RecordSession(wf.name, append([]string{"acousticalc"}, wf.args...), runResult.Stdout, runResult.Stderr, runResult.ExitCode, runResult.Started, runResult.Duration) + if err != nil { + t.Logf("failed to create recording: %v", err) + } else { + recordingPath = path + } + rel = recordingPath + } + + if rel != "" { + if relative, err := filepath.Rel(root, rel); err == nil { + rel = relative + } + } + + if err := reporter.Record(reporting.Result{ + Name: wf.name, + Platform: platform, + ExitCode: runResult.ExitCode, + Status: status, + Stdout: runResult.Stdout, + Stderr: runResult.Stderr, + RecordingPath: rel, + DurationMS: runResult.Duration.Milliseconds(), + Timestamp: runResult.Started, + }); err != nil { + t.Logf("failed to write report: %v", err) + } + } + }) + + result, err := runner.Run(wf.args...) + if err != nil { + t.Fatalf("failed to run CLI: %v", err) + } + + runResult = result + + expectations := wf + if override, ok := wf.platformOverrides[platform]; ok { + if override.expectedExitCode != nil { + expectations.expectedExitCode = *override.expectedExitCode + } + if len(override.expectStdoutSubstrs) > 0 { + expectations.expectStdoutSubstrs = override.expectStdoutSubstrs + } + if len(override.expectStderrSubstrs) > 0 { + expectations.expectStderrSubstrs = override.expectStderrSubstrs + } + } + + if expectations.expectedExitCode != result.ExitCode { + t.Fatalf("expected exit code %d, got %d", expectations.expectedExitCode, result.ExitCode) + } + + normalizedStdout := cross_platform.NormalizeNewlines(strings.TrimSpace(result.Stdout)) + for _, substr := range expectations.expectStdoutSubstrs { + if !strings.Contains(normalizedStdout, substr) { + t.Fatalf("expected stdout to contain %q, got %q", substr, normalizedStdout) + } + } + + normalizedStderr := cross_platform.NormalizeNewlines(strings.TrimSpace(result.Stderr)) + for _, substr := range expectations.expectStderrSubstrs { + if !strings.Contains(normalizedStderr, substr) { + t.Fatalf("expected stderr to contain %q, got %q", substr, normalizedStderr) + } + } + + status = "passed" + }) + } +} diff --git a/tests/e2e/harness.go b/tests/e2e/harness.go new file mode 100644 index 0000000..bedf8fa --- /dev/null +++ b/tests/e2e/harness.go @@ -0,0 +1,128 @@ +package e2e + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "sync" + "time" +) + +var ( + repoRootOnce sync.Once + repoRootPath string + repoRootErr error +) + +func repositoryRoot() (string, error) { + repoRootOnce.Do(func() { + _, filename, _, ok := runtime.Caller(0) + if !ok { + repoRootErr = fmt.Errorf("unable to determine caller information for E2E harness") + return + } + + repoRootPath = filepath.Clean(filepath.Join(filepath.Dir(filename), "..", "..")) + }) + + return repoRootPath, repoRootErr +} + +// CLIRunner orchestrates CLI invocations using `go run` so the end-to-end tests +// remain hermetic without depending on pre-built binaries. The runner supports +// context based cancellation to avoid hanging CI jobs when something goes wrong. +type CLIRunner struct { + repo string + env []string + timeout time.Duration +} + +// NewCLIRunner constructs a runner with repository-relative execution. +func NewCLIRunner(timeout time.Duration) (*CLIRunner, error) { + root, err := repositoryRoot() + if err != nil { + return nil, err + } + + return &CLIRunner{ + repo: root, + env: os.Environ(), + timeout: timeout, + }, nil +} + +// Run executes the acousticalc CLI with the provided arguments. +type RunResult struct { + Stdout string + Stderr string + ExitCode int + Started time.Time + Duration time.Duration +} + +func (r *CLIRunner) Run(args ...string) (*RunResult, error) { + if r.repo == "" { + return nil, fmt.Errorf("runner is not initialised with repository root") + } + + ctx, cancel := context.WithTimeout(context.Background(), r.timeout) + defer cancel() + + cmdArgs := append([]string{"run", "./cmd/acousticalc"}, args...) + cmd := exec.CommandContext(ctx, "go", cmdArgs...) + cmd.Dir = r.repo + cmd.Env = r.env + + stdout, stderr := &safeBuffer{}, &safeBuffer{} + cmd.Stdout = stdout + cmd.Stderr = stderr + + started := time.Now() + err := cmd.Run() + duration := time.Since(started) + + exitCode := 0 + if err != nil { + if ctx.Err() == context.DeadlineExceeded { + return nil, fmt.Errorf("command timeout after %s", r.timeout) + } + + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + return nil, err + } + } + + return &RunResult{ + Stdout: stdout.String(), + Stderr: stderr.String(), + ExitCode: exitCode, + Started: started, + Duration: duration, + }, nil +} + +// safeBuffer is a threadsafe bytes.Buffer alternative that protects against +// concurrent writes from stdout/stderr pipes when commands emit interleaving +// output. +type safeBuffer struct { + mu sync.Mutex + buf []byte +} + +func (b *safeBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + b.buf = append(b.buf, p...) + return len(p), nil +} + +func (b *safeBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return string(b.buf) +} diff --git a/tests/recording/recorder.go b/tests/recording/recorder.go new file mode 100644 index 0000000..1abcc42 --- /dev/null +++ b/tests/recording/recorder.go @@ -0,0 +1,140 @@ +package recording + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// SessionRecorder produces asciinema compatible `.cast` files so GitHub +// Actions jobs can publish demo-ready recordings without requiring the +// asciinema binary. The format is JSON based and easy to generate from Go. +type SessionRecorder struct { + mu sync.Mutex + outDir string + width int + height int + started time.Time +} + +// Metadata represents the header of an asciinema v2 recording file. +type Metadata struct { + Version int `json:"version"` + Width int `json:"width"` + Height int `json:"height"` + Timestamp int64 `json:"timestamp"` + Command string `json:"command"` + Title string `json:"title"` +} + +// Event represents a single frame in the asciinema format. +type Event struct { + Time float64 `json:"time"` + Type string `json:"type"` + Data string `json:"data"` +} + +// NewSessionRecorder initialises a recorder in the provided output directory. +// The directory is created when it does not already exist. +func NewSessionRecorder(outDir string) (*SessionRecorder, error) { + if err := os.MkdirAll(outDir, 0o755); err != nil { + return nil, fmt.Errorf("create recorder directory: %w", err) + } + + return &SessionRecorder{ + outDir: outDir, + width: 80, + height: 24, + started: time.Now(), + }, nil +} + +// RecordSession persists the provided command invocation as an asciinema +// recording. The caller supplies stdout and stderr so the recorder remains pure +// and test friendly. The returned path is relative to the repository root so it +// can be published directly as an artifact. +func (r *SessionRecorder) RecordSession(name string, command []string, stdout, stderr string, exitCode int, started time.Time, duration time.Duration) (string, error) { + r.mu.Lock() + defer r.mu.Unlock() + + safeName := strings.ToLower(name) + safeName = strings.ReplaceAll(safeName, " ", "-") + safeName = strings.ReplaceAll(safeName, "_", "-") + safeName = strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z': + return r + case r >= '0' && r <= '9': + return r + case r == '-': + return r + default: + return -1 + } + }, safeName) + if safeName == "" { + safeName = "session" + } + + filename := fmt.Sprintf("%s-%d.cast", safeName, started.Unix()) + outputPath := filepath.Join(r.outDir, filename) + + f, err := os.Create(outputPath) + if err != nil { + return "", fmt.Errorf("create recording: %w", err) + } + defer f.Close() + + metadata := Metadata{ + Version: 2, + Width: r.width, + Height: r.height, + Timestamp: started.Unix(), + Command: strings.Join(command, " "), + Title: name, + } + + header, err := json.Marshal(metadata) + if err != nil { + return "", fmt.Errorf("encode metadata: %w", err) + } + + if _, err := fmt.Fprintf(f, "%s\n", header); err != nil { + return "", fmt.Errorf("write metadata: %w", err) + } + + events := []Event{ + { + Time: 0, + Type: "o", + Data: stdout, + }, + } + + if stderr != "" { + events = append(events, Event{ + Time: duration.Seconds(), + Type: "o", + Data: fmt.Sprintf("[stderr]%s", stderr), + }) + } + + events = append(events, Event{ + Time: duration.Seconds(), + Type: "o", + Data: fmt.Sprintf("[exit %d]", exitCode), + }) + + encoder := json.NewEncoder(f) + for _, event := range events { + if err := encoder.Encode([]interface{}{event.Time, event.Type, event.Data}); err != nil { + return "", fmt.Errorf("write event: %w", err) + } + } + + return outputPath, nil +} diff --git a/tests/reporting/reporter.go b/tests/reporting/reporter.go new file mode 100644 index 0000000..08014d3 --- /dev/null +++ b/tests/reporting/reporter.go @@ -0,0 +1,65 @@ +package reporting + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +// Result captures the outcome of a single E2E workflow execution. +type Result struct { + Name string `json:"name"` + Platform string `json:"platform"` + ExitCode int `json:"exit_code"` + Status string `json:"status"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + RecordingPath string `json:"recording_path"` + DurationMS int64 `json:"duration_ms"` + Timestamp time.Time `json:"timestamp"` +} + +// Reporter aggregates workflow results into a single JSON artifact that can be +// published from CI and consumed by downstream reporting pipelines. +type Reporter struct { + mu sync.Mutex + outputPath string + results []Result +} + +// NewReporter constructs a Reporter that will emit JSON to the provided path. +func NewReporter(outputPath string) *Reporter { + return &Reporter{outputPath: outputPath} +} + +// Record adds a new result to the aggregation and immediately flushes it to +// disk. Immediate flushing keeps the artifact in sync even if an individual test +// fails after the recording is created. +func (r *Reporter) Record(result Result) error { + r.mu.Lock() + defer r.mu.Unlock() + + r.results = append(r.results, result) + + if err := os.MkdirAll(filepath.Dir(r.outputPath), 0o755); err != nil { + return fmt.Errorf("create report directory: %w", err) + } + + f, err := os.Create(r.outputPath) + if err != nil { + return fmt.Errorf("create report file: %w", err) + } + defer f.Close() + + encoder := json.NewEncoder(f) + encoder.SetIndent("", " ") + + if err := encoder.Encode(r.results); err != nil { + return fmt.Errorf("encode report: %w", err) + } + + return nil +} diff --git a/tests/scripts/Makefile b/tests/scripts/Makefile index 10a07d4..e394982 100644 --- a/tests/scripts/Makefile +++ b/tests/scripts/Makefile @@ -21,7 +21,14 @@ UNIX_SCRIPT := $(TESTS_DIR)/scripts/unix_test_tools.sh # Unix-specific detection UNAME_S := $(shell uname -s) -NPROC := $(shell nproc) + +# Determine available CPU cores with a cross-platform fallback. +# macOS does not provide `nproc`, so we fall back to `sysctl` when needed. +ifeq (,$(shell command -v nproc 2>/dev/null)) + NPROC := $(shell sysctl -n hw.ncpu 2>/dev/null || echo 1) +else + NPROC := $(shell nproc 2>/dev/null || echo 1) +endif # Environment variables export PARALLEL_JOBS ?= $(NPROC) diff --git a/tests/visual/robotgo_engine.go b/tests/visual/robotgo_engine.go new file mode 100644 index 0000000..5fe4f3c --- /dev/null +++ b/tests/visual/robotgo_engine.go @@ -0,0 +1,41 @@ +//go:build visualtests + +package visual + +import ( + "fmt" + "image" + "runtime" + + "github.com/go-vgo/robotgo" +) + +type RobotGoEngine struct { + platform string +} + +func NewRobotGoEngine() ScreenshotEngine { + return &RobotGoEngine{platform: runtime.GOOS} +} + +func (rg *RobotGoEngine) Capture() (image.Image, error) { + bitmap := robotgo.CaptureScreen() + if bitmap == nil { + return nil, fmt.Errorf("failed to capture screen with robotgo") + } + defer robotgo.FreeBitmap(bitmap) + + img := robotgo.ToImage(bitmap) + if img == nil { + return nil, fmt.Errorf("failed to convert bitmap to image") + } + return img, nil +} + +func (rg *RobotGoEngine) GetPlatform() string { + return rg.platform +} + +func (rg *RobotGoEngine) IsAvailable() bool { + return true +} diff --git a/tests/visual/robotgo_engine_stub.go b/tests/visual/robotgo_engine_stub.go new file mode 100644 index 0000000..962e11d --- /dev/null +++ b/tests/visual/robotgo_engine_stub.go @@ -0,0 +1,26 @@ +//go:build !visualtests + +package visual + +import ( + "errors" + "image" +) + +func NewRobotGoEngine() ScreenshotEngine { + return &RobotGoEngine{} +} + +type RobotGoEngine struct{} + +func (rg *RobotGoEngine) Capture() (image.Image, error) { + return nil, errors.New("robotgo engine unavailable; rebuild with -tags visualtests") +} + +func (rg *RobotGoEngine) GetPlatform() string { + return "unavailable" +} + +func (rg *RobotGoEngine) IsAvailable() bool { + return false +} diff --git a/tests/visual/visual_utils.go b/tests/visual/visual_utils.go index 10f9e4e..5b7289a 100644 --- a/tests/visual/visual_utils.go +++ b/tests/visual/visual_utils.go @@ -3,6 +3,8 @@ package visual import ( "context" "fmt" + "image" + "image/color" "os" "path/filepath" "runtime" @@ -10,7 +12,6 @@ import ( "time" "github.com/disintegration/imaging" - "github.com/go-vgo/robotgo" ) // ScreenshotCapturer defines the interface for screenshot capture implementations @@ -32,75 +33,51 @@ type ScreenshotCapture struct { // ScreenshotEngine abstracts the underlying screenshot mechanism type ScreenshotEngine interface { - Capture() ([]byte, error) - GetImageData() (interface{}, error) + Capture() (image.Image, error) GetPlatform() string IsAvailable() bool } -// RobotGoEngine implements ScreenshotEngine using robotgo -type RobotGoEngine struct { - platform string -} - -func NewRobotGoEngine() *RobotGoEngine { - return &RobotGoEngine{ - platform: runtime.GOOS, - } -} - -func (rg *RobotGoEngine) Capture() ([]byte, error) { - bitmap := robotgo.CaptureScreen() - if bitmap == nil { - return nil, fmt.Errorf("failed to capture screen with robotgo") - } - // Return bitmap data - simplified for interface - return []byte{}, nil -} - -func (rg *RobotGoEngine) GetImageData() (interface{}, error) { - bitmap := robotgo.CaptureScreen() - if bitmap == nil { - return nil, fmt.Errorf("failed to capture screen") - } - return robotgo.ToImage(bitmap), nil -} - -func (rg *RobotGoEngine) GetPlatform() string { - return rg.platform -} - -func (rg *RobotGoEngine) IsAvailable() bool { - // Check if robotgo is available on this platform - return rg.platform == "darwin" || rg.platform == "linux" -} - // ScreenshotEngineFactory implements Strategy pattern for engine selection type ScreenshotEngineFactory struct{} func (sef *ScreenshotEngineFactory) CreateEngine() ScreenshotEngine { engine := NewRobotGoEngine() - if engine.IsAvailable() { + if engine != nil && engine.IsAvailable() { return engine } - // Fallback to a mock engine for unsupported platforms - return &MockScreenshotEngine{} + // Fallback to a mock engine for unsupported platforms or when visual + // dependencies are unavailable. The mock still produces a deterministic + // image so downstream consumers can exercise report-generation paths + // without requiring CGO/X11 capabilities. + return &MockScreenshotEngine{platform: runtime.GOOS} } // MockScreenshotEngine provides fallback for unsupported platforms -type MockScreenshotEngine struct{} - -func (m *MockScreenshotEngine) Capture() ([]byte, error) { - return []byte("mock-screenshot-data"), nil +type MockScreenshotEngine struct { + platform string } -func (m *MockScreenshotEngine) GetImageData() (interface{}, error) { - return "mock-image-data", nil +func (m *MockScreenshotEngine) Capture() (image.Image, error) { + img := image.NewNRGBA(image.Rect(0, 0, 640, 360)) + for y := 0; y < img.Bounds().Dy(); y++ { + for x := 0; x < img.Bounds().Dx(); x++ { + // Create a simple gradient so the PNG has non-trivial size and + // deterministically unique pixels. + img.Set(x, y, color.NRGBA{ + R: uint8((x * 3) % 255), + G: uint8((y * 5) % 255), + B: uint8((x + y) % 255), + A: 255, + }) + } + } + return img, nil } func (m *MockScreenshotEngine) GetPlatform() string { - return "mock" + return m.platform } func (m *MockScreenshotEngine) IsAvailable() bool { @@ -137,6 +114,11 @@ func (sc *ScreenshotCapture) CaptureScreen(eventType string) (string, error) { return "", fmt.Errorf("failed to create output directory: %w", err) } + if sc.capturer == nil { + factory := &ScreenshotEngineFactory{} + sc.capturer = factory.CreateEngine() + } + // Generate filename with timestamp and event type filename := fmt.Sprintf("%s_%s_%s.png", sc.TestName, @@ -145,17 +127,9 @@ func (sc *ScreenshotCapture) CaptureScreen(eventType string) (string, error) { filePath := filepath.Join(sc.OutputDir, filename) - // Capture screenshot using robotgo (maintaining proven functionality) - // Future: This can be abstracted through the capturer interface when needed - bitmap := robotgo.CaptureScreen() - if bitmap == nil { - return "", fmt.Errorf("failed to capture screen") - } - - // Convert robotgo bitmap to standard image - img := robotgo.ToImage(bitmap) - if img == nil { - return "", fmt.Errorf("failed to convert bitmap to image") + img, err := sc.capturer.Capture() + if err != nil { + return "", fmt.Errorf("failed to capture screen: %w", err) } // Save with PNG format for lossless compression