diff --git a/go.mod b/go.mod index 1ded1dcd15..cb3b841fdb 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/cli/safeexec v1.0.1 // indirect + github.com/creack/pty v1.1.24 // indirect github.com/fatih/color v1.7.0 // indirect github.com/henvic/httpretty v0.1.4 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/go.sum b/go.sum index 058bdc3a44..68f8f0ac7e 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ github.com/cli/shurcooL-graphql v0.0.4 h1:6MogPnQJLjKkaXPyGqPRXOI2qCsQdqNfUY1QSJ github.com/cli/shurcooL-graphql v0.0.4/go.mod h1:3waN4u02FiZivIV+p1y4d0Jo1jc6BViMA73C+sZo2fk= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= diff --git a/pkg/cli/compile_integration_test.go b/pkg/cli/compile_integration_test.go new file mode 100644 index 0000000000..2dd3c67269 --- /dev/null +++ b/pkg/cli/compile_integration_test.go @@ -0,0 +1,248 @@ +package cli + +import ( + "bytes" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/creack/pty" +) + +// integrationTestSetup holds the setup state for integration tests +type integrationTestSetup struct { + tempDir string + originalWd string + binaryPath string + workflowsDir string + cleanup func() +} + +// setupIntegrationTest creates a temporary directory and builds the gh-aw binary +// This is the equivalent of @Before in Java - common setup for all integration tests +func setupIntegrationTest(t *testing.T) *integrationTestSetup { + t.Helper() + + // Create a temporary directory for the test + tempDir, err := os.MkdirTemp("", "gh-aw-compile-integration-*") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + + // Save current working directory and change to temp directory + originalWd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current working directory: %v", err) + } + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("Failed to change to temp directory: %v", err) + } + + // Build the gh-aw binary + binaryPath := filepath.Join(tempDir, "gh-aw") + buildCmd := exec.Command("make") + projectRoot := filepath.Join(originalWd, "..", "..") + buildCmd.Dir = projectRoot + buildCmd.Stderr = os.Stderr + if err := buildCmd.Run(); err != nil { + t.Fatalf("Failed to build gh-aw binary: %v", err) + } + // move binary to temp directory + if err := os.Rename(filepath.Join(projectRoot, "gh-aw"), binaryPath); err != nil { + t.Fatalf("Failed to move gh-aw binary to temp directory: %v", err) + } + + // Create .github/workflows directory + workflowsDir := ".github/workflows" + if err := os.MkdirAll(workflowsDir, 0755); err != nil { + t.Fatalf("Failed to create workflows directory: %v", err) + } + + // Setup cleanup function + cleanup := func() { + err := os.Chdir(originalWd) + if err != nil { + t.Fatalf("Failed to change back to original working directory: %v", err) + } + err = os.RemoveAll(tempDir) + if err != nil { + t.Fatalf("Failed to remove temp directory: %v", err) + } + } + + return &integrationTestSetup{ + tempDir: tempDir, + originalWd: originalWd, + binaryPath: binaryPath, + workflowsDir: workflowsDir, + cleanup: cleanup, + } +} + +// TestCompileIntegration tests the compile command by executing the gh-aw CLI binary +func TestCompileIntegration(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + // Create a test markdown workflow file + testWorkflow := `--- +name: Integration Test Workflow +on: + workflow_dispatch: +permissions: + contents: read +engine: claude +--- + +# Integration Test Workflow + +This is a simple integration test workflow. + +Please check the repository for any open issues and create a summary. +` + + testWorkflowPath := filepath.Join(setup.workflowsDir, "test.md") + if err := os.WriteFile(testWorkflowPath, []byte(testWorkflow), 0644); err != nil { + t.Fatalf("Failed to write test workflow file: %v", err) + } + + // Run the compile command + cmd := exec.Command(setup.binaryPath, "compile", testWorkflowPath) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("CLI compile command failed: %v\nOutput: %s", err, string(output)) + } + + // Check that the compiled .lock.yml file was created + lockFilePath := filepath.Join(setup.workflowsDir, "test.lock.yml") + if _, err := os.Stat(lockFilePath); os.IsNotExist(err) { + t.Fatalf("Expected lock file %s was not created", lockFilePath) + } + + // Read and verify the generated lock file contains expected content + lockContent, err := os.ReadFile(lockFilePath) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + lockContentStr := string(lockContent) + if !strings.Contains(lockContentStr, "name: \"Integration Test Workflow\"") { + t.Errorf("Lock file should contain the workflow name") + } + + if !strings.Contains(lockContentStr, "workflow_dispatch") { + t.Errorf("Lock file should contain the trigger event") + } + + if !strings.Contains(lockContentStr, "jobs:") { + t.Errorf("Lock file should contain jobs section") + } + + t.Logf("Integration test passed - successfully compiled workflow to %s", lockFilePath) +} + +func TestCompileWithIncludeWithEmptyFrontmatterUnderPty(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + // Create an include file + includeContent := `--- +--- +# Included Workflow + +This is an included workflow file. +` + includeFile := filepath.Join(setup.workflowsDir, "include.md") + if err := os.WriteFile(includeFile, []byte(includeContent), 0644); err != nil { + t.Fatalf("Failed to write include file: %v", err) + } + + // Create a test markdown workflow file + testWorkflow := `--- +name: Integration Test Workflow +on: + workflow_dispatch: +permissions: + contents: read +engine: claude +--- + +# Integration Test Workflow + +This is a simple integration test workflow. + +Please check the repository for any open issues and create a summary. + +@include include.md +` + testWorkflowPath := filepath.Join(setup.workflowsDir, "test.md") + if err := os.WriteFile(testWorkflowPath, []byte(testWorkflow), 0644); err != nil { + t.Fatalf("Failed to write test workflow file: %v", err) + } + + // Run the compile command + cmd := exec.Command(setup.binaryPath, "compile", testWorkflowPath) + // Start the command with a TTY attached to stdin/stdout/stderr + ptmx, err := pty.Start(cmd) + if err != nil { + t.Fatalf("failed to start PTY: %v", err) + } + defer func() { _ = ptmx.Close() }() // Best effort + + // Capture all output from the PTY + var buf bytes.Buffer + done := make(chan struct{}) + go func() { + _, _ = io.Copy(&buf, ptmx) // reads both stdout/stderr via the PTY + close(done) + }() + + // Wait for the process to finish + err = cmd.Wait() + + // Ensure reader goroutine drains remaining output + select { + case <-done: + case <-time.After(750 * time.Millisecond): + } + + output := buf.String() + if err != nil { + t.Fatalf("CLI compile command failed: %v\nOutput:\n%s", err, output) + } + + // Check that the compiled .lock.yml file was created + lockFilePath := filepath.Join(setup.workflowsDir, "test.lock.yml") + if _, err := os.Stat(lockFilePath); os.IsNotExist(err) { + t.Fatalf("Expected lock file %s was not created", lockFilePath) + } + + // Read and verify the generated lock file contains expected content + lockContent, err := os.ReadFile(lockFilePath) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + lockContentStr := string(lockContent) + if !strings.Contains(lockContentStr, "name: \"Integration Test Workflow\"") { + t.Errorf("Lock file should contain the workflow name") + } + + if !strings.Contains(lockContentStr, "workflow_dispatch") { + t.Errorf("Lock file should contain the trigger event") + } + + if !strings.Contains(lockContentStr, "jobs:") { + t.Errorf("Lock file should contain jobs section") + } + + if strings.Contains(lockContentStr, "\x1b[") { + t.Errorf("Lock file must not contain color escape sequences") + } + + t.Logf("Integration test passed - successfully compiled workflow to %s", lockFilePath) +} diff --git a/pkg/parser/frontmatter.go b/pkg/parser/frontmatter.go index 023f537837..89299ace21 100644 --- a/pkg/parser/frontmatter.go +++ b/pkg/parser/frontmatter.go @@ -314,7 +314,8 @@ func ProcessIncludes(content, baseDir string, extractTools bool) (string, error) if extractTools { result.WriteString("{}\n") } else { - result.WriteString(fmt.Sprintf("\n\n\n", err.Error())) + strippedError := StripANSI(err.Error()) + result.WriteString(fmt.Sprintf("\n\n\n", strippedError)) } continue } @@ -325,7 +326,8 @@ func ProcessIncludes(content, baseDir string, extractTools bool) (string, error) if extractTools { result.WriteString("{}\n") } else { - result.WriteString(fmt.Sprintf("\n\n\n", err.Error())) + strippedError := StripANSI(err.Error()) + result.WriteString(fmt.Sprintf("\n\n\n", strippedError)) } continue } @@ -865,3 +867,110 @@ func areEqual(a, b any) bool { return string(aJSON) == string(bJSON) } + +// StripANSI removes all ANSI escape sequences from a string +// This handles: +// - CSI (Control Sequence Introducer) sequences: \x1b[... +// - OSC (Operating System Command) sequences: \x1b]...\x07 or \x1b]...\x1b\\ +// - Simple escape sequences: \x1b followed by a single character +func StripANSI(s string) string { + if s == "" { + return s + } + + var result strings.Builder + result.Grow(len(s)) // Pre-allocate capacity for efficiency + + i := 0 + for i < len(s) { + if s[i] == '\x1b' { + if i+1 >= len(s) { + // ESC at end of string, skip it + i++ + continue + } + // Found ESC character, determine sequence type + switch s[i+1] { + case '[': + // CSI sequence: \x1b[...final_char + // Parameters are in range 0x30-0x3F (0-?), intermediate chars 0x20-0x2F (space-/) + // Final characters are in range 0x40-0x7E (@-~) + i += 2 // Skip ESC and [ + for i < len(s) { + if isFinalCSIChar(s[i]) { + i++ // Skip the final character + break + } else if isCSIParameterChar(s[i]) { + i++ // Skip parameter/intermediate character + } else { + // Invalid character in CSI sequence, stop processing this escape + break + } + } + case ']': + // OSC sequence: \x1b]...terminator + // Terminators: \x07 (BEL) or \x1b\\ (ST) + i += 2 // Skip ESC and ] + for i < len(s) { + if s[i] == '\x07' { + i++ // Skip BEL + break + } else if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '\\' { + i += 2 // Skip ESC and \ + break + } + i++ + } + case '(': + // G0 character set selection: \x1b(char + i += 2 // Skip ESC and ( + if i < len(s) { + i++ // Skip the character + } + case ')': + // G1 character set selection: \x1b)char + i += 2 // Skip ESC and ) + if i < len(s) { + i++ // Skip the character + } + case '=': + // Application keypad mode: \x1b= + i += 2 + case '>': + // Normal keypad mode: \x1b> + i += 2 + case 'c': + // Reset: \x1bc + i += 2 + default: + // Other escape sequences (2-character) + // Handle common ones like \x1b7, \x1b8, \x1bD, \x1bE, \x1bH, \x1bM + if i+1 < len(s) && (s[i+1] >= '0' && s[i+1] <= '~') { + i += 2 + } else { + // Invalid or incomplete escape sequence, just skip ESC + i++ + } + } + } else { + // Regular character, keep it + result.WriteByte(s[i]) + i++ + } + } + + return result.String() +} + +// isFinalCSIChar checks if a character is a valid CSI final character +// Final characters are in range 0x40-0x7E (@-~) +func isFinalCSIChar(b byte) bool { + return b >= 0x40 && b <= 0x7E +} + +// isCSIParameterChar checks if a character is a valid CSI parameter or intermediate character +// Parameter characters are in range 0x30-0x3F (0-?) +// Intermediate characters are in range 0x20-0x2F (space-/) +func isCSIParameterChar(b byte) bool { + return (b >= 0x20 && b <= 0x2F) || (b >= 0x30 && b <= 0x3F) +} diff --git a/pkg/parser/frontmatter_test.go b/pkg/parser/frontmatter_test.go index b7ffcd0f96..2834485c45 100644 --- a/pkg/parser/frontmatter_test.go +++ b/pkg/parser/frontmatter_test.go @@ -1416,3 +1416,298 @@ func TestMergeToolsFromJSON(t *testing.T) { }) } } + +// Test StripANSI function +func TestStripANSI(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "empty string", + input: "", + expected: "", + }, + { + name: "plain text without ANSI", + input: "Hello World", + expected: "Hello World", + }, + { + name: "simple CSI color sequence", + input: "\x1b[31mRed Text\x1b[0m", + expected: "Red Text", + }, + { + name: "multiple CSI sequences", + input: "\x1b[1m\x1b[31mBold Red\x1b[0m\x1b[32mGreen\x1b[0m", + expected: "Bold RedGreen", + }, + { + name: "CSI cursor movement", + input: "Line 1\x1b[2;1HLine 2", + expected: "Line 1Line 2", + }, + { + name: "CSI erase sequences", + input: "Text\x1b[2JCleared\x1b[K", + expected: "TextCleared", + }, + { + name: "OSC sequence with BEL terminator", + input: "\x1b]0;Window Title\x07Content", + expected: "Content", + }, + { + name: "OSC sequence with ST terminator", + input: "\x1b]2;Terminal Title\x1b\\More content", + expected: "More content", + }, + { + name: "character set selection G0", + input: "\x1b(0Hello\x1b(B", + expected: "Hello", + }, + { + name: "character set selection G1", + input: "\x1b)0World\x1b)B", + expected: "World", + }, + { + name: "keypad mode sequences", + input: "\x1b=Keypad\x1b>Normal", + expected: "KeypadNormal", + }, + { + name: "reset sequence", + input: "Before\x1bcAfter", + expected: "BeforeAfter", + }, + { + name: "save and restore cursor", + input: "Start\x1b7Middle\x1b8End", + expected: "StartMiddleEnd", + }, + { + name: "index and reverse index", + input: "Text\x1bDDown\x1bMUp", + expected: "TextDownUp", + }, + { + name: "next line and horizontal tab set", + input: "Line\x1bENext\x1bHTab", + expected: "LineNextTab", + }, + { + name: "complex CSI with parameters", + input: "\x1b[38;5;196mBright Red\x1b[48;5;21mBlue BG\x1b[0m", + expected: "Bright RedBlue BG", + }, + { + name: "CSI with semicolon parameters", + input: "\x1b[1;31;42mBold red on green\x1b[0m", + expected: "Bold red on green", + }, + { + name: "malformed escape at end", + input: "Text\x1b", + expected: "Text", + }, + { + name: "malformed CSI at end", + input: "Text\x1b[31", + expected: "Text", + }, + { + name: "malformed OSC at end", + input: "Text\x1b]0;Title", + expected: "Text", + }, + { + name: "escape followed by invalid character", + input: "Text\x1bXInvalid", + expected: "TextInvalid", + }, + { + name: "consecutive escapes", + input: "\x1b[31m\x1b[1m\x1b[4mText\x1b[0m", + expected: "Text", + }, + { + name: "mixed content with newlines", + input: "Line 1\n\x1b[31mRed Line 2\x1b[0m\nLine 3", + expected: "Line 1\nRed Line 2\nLine 3", + }, + { + name: "common terminal output", + input: "\x1b[?25l\x1b[2J\x1b[H\x1b[32m✓\x1b[0m Success", + expected: "✓ Success", + }, + { + name: "git diff style colors", + input: "\x1b[32m+Added line\x1b[0m\n\x1b[31m-Removed line\x1b[0m", + expected: "+Added line\n-Removed line", + }, + { + name: "unicode content with ANSI", + input: "\x1b[33m🎉 Success! 测试\x1b[0m", + expected: "🎉 Success! 测试", + }, + { + name: "very long CSI sequence", + input: "\x1b[1;2;3;4;5;6;7;8;9;10;11;12;13;14;15mLong params\x1b[0m", + expected: "Long params", + }, + { + name: "CSI with question mark private parameter", + input: "\x1b[?25hCursor visible\x1b[?25l", + expected: "Cursor visible", + }, + { + name: "CSI with greater than private parameter", + input: "\x1b[>0cDevice attributes\x1b[>1c", + expected: "Device attributes", + }, + { + name: "all final CSI characters test", + input: "\x1b[@\x1b[A\x1b[B\x1b[C\x1b[D\x1b[E\x1b[F\x1b[G\x1b[H\x1b[I\x1b[J\x1b[K\x1b[L\x1b[M\x1b[N\x1b[O\x1b[P\x1b[Q\x1b[R\x1b[S\x1b[T\x1b[U\x1b[V\x1b[W\x1b[X\x1b[Y\x1b[Z\x1b[[\x1b[\\\x1b[]\x1b[^\x1b[_\x1b[`\x1b[a\x1b[b\x1b[c\x1b[d\x1b[e\x1b[f\x1b[g\x1b[h\x1b[i\x1b[j\x1b[k\x1b[l\x1b[m\x1b[n\x1b[o\x1b[p\x1b[q\x1b[r\x1b[s\x1b[t\x1b[u\x1b[v\x1b[w\x1b[x\x1b[y\x1b[z\x1b[{\x1b[|\x1b[}\x1b[~Text", + expected: "Text", + }, + { + name: "CSI with invalid final character", + input: "Before\x1b[31Text after", + expected: "Beforeext after", + }, + { + name: "real world lipgloss output", + input: "\x1b[1;38;2;80;250;123m✓\x1b[0;38;2;248;248;242m Success message\x1b[0m", + expected: "✓ Success message", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := StripANSI(tt.input) + if result != tt.expected { + t.Errorf("StripANSI(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +// Test isCSIParameterChar function +func TestIsCSIParameterChar(t *testing.T) { + tests := []struct { + name string + char byte + expected bool + }{ + // Valid parameter characters (0x30-0x3F, 0-?) + {name: "0 (0x30)", char: '0', expected: true}, + {name: "9 (0x39)", char: '9', expected: true}, + {name: "; (0x3B)", char: ';', expected: true}, + {name: "? (0x3F)", char: '?', expected: true}, + + // Valid intermediate characters (0x20-0x2F, space-/) + {name: "space (0x20)", char: ' ', expected: true}, + {name: "! (0x21)", char: '!', expected: true}, + {name: "/ (0x2F)", char: '/', expected: true}, + + // Invalid characters (below 0x20) + {name: "tab (0x09)", char: '\t', expected: false}, + {name: "newline (0x0A)", char: '\n', expected: false}, + {name: "null (0x00)", char: 0x00, expected: false}, + + // Invalid characters (above 0x3F) + {name: "@ (0x40)", char: '@', expected: false}, + {name: "A (0x41)", char: 'A', expected: false}, + {name: "m (0x6D)", char: 'm', expected: false}, + {name: "~ (0x7E)", char: '~', expected: false}, + {name: "DEL (0x7F)", char: 0x7F, expected: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isCSIParameterChar(tt.char) + if result != tt.expected { + t.Errorf("isCSIParameterChar(%q/0x%02X) = %v, want %v", tt.char, tt.char, result, tt.expected) + } + }) + } +} + +// Test isFinalCSIChar function +func TestIsFinalCSIChar(t *testing.T) { + tests := []struct { + name string + char byte + expected bool + }{ + // Valid final characters (0x40-0x7E, @-~) + {name: "@ (0x40)", char: '@', expected: true}, + {name: "A (0x41)", char: 'A', expected: true}, + {name: "Z (0x5A)", char: 'Z', expected: true}, + {name: "a (0x61)", char: 'a', expected: true}, + {name: "m (0x6D)", char: 'm', expected: true}, // Common color final char + {name: "~ (0x7E)", char: '~', expected: true}, + + // Invalid characters (below 0x40) + {name: "space (0x20)", char: ' ', expected: false}, + {name: "0 (0x30)", char: '0', expected: false}, + {name: "9 (0x39)", char: '9', expected: false}, + {name: "; (0x3B)", char: ';', expected: false}, + {name: "? (0x3F)", char: '?', expected: false}, + + // Invalid characters (above 0x7E) + {name: "DEL (0x7F)", char: 0x7F, expected: false}, + {name: "high byte (0x80)", char: 0x80, expected: false}, + {name: "high byte (0xFF)", char: 0xFF, expected: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isFinalCSIChar(tt.char) + if result != tt.expected { + t.Errorf("isFinalCSIChar(%q/0x%02X) = %v, want %v", tt.char, tt.char, result, tt.expected) + } + }) + } +} + +// Benchmark StripANSI function for performance +func BenchmarkStripANSI(b *testing.B) { + testCases := []struct { + name string + input string + }{ + { + name: "plain text", + input: "This is plain text without any ANSI codes", + }, + { + name: "simple color", + input: "\x1b[31mRed text\x1b[0m", + }, + { + name: "complex formatting", + input: "\x1b[1;38;2;255;0;0m\x1b[48;2;0;255;0mComplex formatting\x1b[0m", + }, + { + name: "mixed content", + input: "Normal \x1b[31mred\x1b[0m normal \x1b[32mgreen\x1b[0m normal \x1b[34mblue\x1b[0m text", + }, + { + name: "long text with ANSI", + input: strings.Repeat("\x1b[31mRed \x1b[32mGreen \x1b[34mBlue\x1b[0m ", 100), + }, + } + + for _, tc := range testCases { + b.Run(tc.name, func(b *testing.B) { + for i := 0; i < b.N; i++ { + StripANSI(tc.input) + } + }) + } +}