Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/integration-agentics.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ jobs:

- name: Install development dependencies
run: make deps-dev
env:
GH_TOKEN: ${{ github.token }}

- name: Build gh-aw binary
run: make build
Expand Down
9 changes: 8 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,14 @@ deps:
go install golang.org/x/tools/gopls@latest
go install github.com/rhysd/actionlint/cmd/actionlint@latest

# Install act tool for local workflow testing
.PHONY: install-act
install-act:
gh extension install https://github.com/nektos/gh-act

# Install development tools (including linter)
.PHONY: deps-dev
deps-dev: deps copy-copilot-to-claude download-github-actions-schema
deps-dev: deps copy-copilot-to-claude download-github-actions-schema install-act
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
npm ci

Expand Down Expand Up @@ -245,6 +250,8 @@ help:
@echo " test-coverage - Run tests with coverage report"
@echo " clean - Clean build artifacts"
@echo " deps - Install dependencies"
@echo " deps-dev - Install development dependencies (includes act)"
@echo " install-act - Install act tool for local workflow testing"
@echo " lint - Run linter"
@echo " fmt - Format code"
@echo " fmt-cjs - Format JavaScript (.cjs) files"
Expand Down
1 change: 1 addition & 0 deletions cmd/gh-aw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@ func init() {
rootCmd.AddCommand(uninstallCmd)
rootCmd.AddCommand(compileCmd)
rootCmd.AddCommand(runCmd)
rootCmd.AddCommand(cli.NewTestCommand())
rootCmd.AddCommand(removeCmd)
rootCmd.AddCommand(statusCmd)
rootCmd.AddCommand(enableCmd)
Expand Down
171 changes: 171 additions & 0 deletions pkg/cli/commands_test_local_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package cli

import (
"os"
"path/filepath"
"testing"
)

func TestCheckActInstalled(t *testing.T) {
tests := []struct {
name string
verbose bool
wantErr bool
}{
{
name: "verbose mode",
verbose: true,
wantErr: true, // Should error if act is not in PATH (as expected in test environment)
},
{
name: "quiet mode",
verbose: false,
wantErr: true, // Should error if act is not in PATH (as expected in test environment)
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := checkActInstalled(tt.verbose)

Check failure on line 29 in pkg/cli/commands_test_local_test.go

View workflow job for this annotation

GitHub Actions / Run Unit Tests

undefined: checkActInstalled

Check failure on line 29 in pkg/cli/commands_test_local_test.go

View workflow job for this annotation

GitHub Actions / Lint Code

undefined: checkActInstalled (typecheck)

Check failure on line 29 in pkg/cli/commands_test_local_test.go

View workflow job for this annotation

GitHub Actions / Run Unit Tests

undefined: checkActInstalled

Check failure on line 29 in pkg/cli/commands_test_local_test.go

View workflow job for this annotation

GitHub Actions / Lint Code

undefined: checkActInstalled (typecheck)
if (err != nil) != tt.wantErr {
t.Errorf("checkActInstalled() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

func TestTestWorkflowLocallyValidation(t *testing.T) {
tests := []struct {
name string
workflowName string
event string
verbose bool
wantErr bool
}{
{
name: "empty workflow name",
workflowName: "",
event: "workflow_dispatch",
verbose: false,
wantErr: true,
},
{
name: "single workflow test",
workflowName: "test-workflow",
event: "workflow_dispatch",
verbose: false,
wantErr: true, // Will error on missing workflow file
},
{
name: "custom event type",
workflowName: "test-workflow",
event: "push",
verbose: true,
wantErr: true, // Will error on missing workflow file
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := TestWorkflowLocally(tt.workflowName, tt.event, tt.verbose)
if (err != nil) != tt.wantErr {
t.Errorf("TestWorkflowLocally() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

func TestTestSingleWorkflowLocally(t *testing.T) {
// Create a temporary test environment
tempDir := t.TempDir()

// Create a mock .github/workflows directory
workflowsDir := filepath.Join(tempDir, ".github", "workflows")
if err := os.MkdirAll(workflowsDir, 0755); err != nil {
t.Fatalf("Failed to create workflows directory: %v", err)
}

// Create a mock markdown file first
mdFile := filepath.Join(workflowsDir, "test-workflow.md")
mdContent := `---
on:
workflow_dispatch:
---

# Test Workflow

This is a test workflow.
`
if err := os.WriteFile(mdFile, []byte(mdContent), 0644); err != nil {
t.Fatalf("Failed to create mock markdown file: %v", err)
}

// Create a corresponding mock lock file
lockFile := filepath.Join(workflowsDir, "test-workflow.lock.yml")
lockContent := `name: Test Workflow
on:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: echo "test"
`
if err := os.WriteFile(lockFile, []byte(lockContent), 0644); err != nil {
t.Fatalf("Failed to create mock lock file: %v", err)
}

// Change to temp directory
oldDir, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get current directory: %v", err)
}
defer func() {
if err := os.Chdir(oldDir); err != nil {
t.Errorf("Failed to restore directory: %v", err)
}
}()

if err := os.Chdir(tempDir); err != nil {
t.Fatalf("Failed to change to temp directory: %v", err)
}

tests := []struct {
name string
workflowName string
event string
verbose bool
wantErr bool
}{
{
name: "valid workflow test",
workflowName: "test-workflow",
event: "workflow_dispatch",
verbose: false,
wantErr: true, // Will still error due to missing act installation in test environment
},
{
name: "valid workflow test verbose",
workflowName: "test-workflow",
event: "push",
verbose: true,
wantErr: true, // Will still error due to missing act installation in test environment
},
{
name: "non-existent workflow",
workflowName: "missing-workflow",
event: "workflow_dispatch",
verbose: false,
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := testSingleWorkflowLocally(tt.workflowName, tt.event, tt.verbose)
if (err != nil) != tt.wantErr {
t.Errorf("testSingleWorkflowLocally() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
156 changes: 156 additions & 0 deletions pkg/cli/test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package cli

import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"

"github.com/githubnext/gh-aw/pkg/console"
"github.com/githubnext/gh-aw/pkg/workflow"
"github.com/spf13/cobra"
)

// NewTestCommand creates the test command for local workflow execution with act
func NewTestCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "test <workflow-id-or-name>",
Short: "Test an agentic workflow locally using Docker and act",
Long: `Test an agentic workflow locally using Docker and the nektos/act tool.

This command compiles the workflow and runs it locally in Docker containers instead of GitHub Actions.
It automatically detects and installs the 'act' tool if not available.

The workflow must have been added as an action and compiled.
This command works with workflows that have workflow_dispatch, push, pull_request, or other triggers.

Examples:
gh aw test weekly-research
gh aw test weekly-research --event workflow_dispatch`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
event, _ := cmd.Flags().GetString("event")
verbose, _ := cmd.Flags().GetBool("verbose")

if err := TestWorkflowLocally(args[0], event, verbose); err != nil {
fmt.Fprintln(os.Stderr, console.FormatError(console.CompilerError{
Type: "error",
Message: fmt.Sprintf("testing workflow locally: %v", err),
}))
os.Exit(1)
}
},
}

// Add flags to test command
cmd.Flags().StringP("event", "e", "workflow_dispatch", "Event type to simulate (workflow_dispatch, push, pull_request, etc.)")

return cmd
}

// TestWorkflowLocally runs a single agentic workflow locally using Docker and act
func TestWorkflowLocally(workflowName, event string, verbose bool) error {
if workflowName == "" {
return fmt.Errorf("workflow name or ID is required")
}

// Compile workflow first to ensure it's up to date (with safe-outputs staged if any)
fmt.Println(console.FormatProgressMessage("Compiling workflow before local testing..."))
if err := CompileWorkflowForTesting(workflowName, verbose); err != nil {
return fmt.Errorf("failed to compile workflow: %w", err)
}

fmt.Println(console.FormatSuccessMessage("Workflow compiled successfully"))

if err := testSingleWorkflowLocally(workflowName, event, verbose); err != nil {
return fmt.Errorf("failed to test workflow '%s': %w", workflowName, err)
}

fmt.Println(console.FormatSuccessMessage(fmt.Sprintf("Successfully tested workflow: %s", workflowName)))
return nil
}

// testSingleWorkflowLocally tests a single workflow locally using act
func testSingleWorkflowLocally(workflowName, event string, verbose bool) error {
// Resolve workflow file
workflowFile, err := resolveWorkflowFile(workflowName, verbose)
if err != nil {
return fmt.Errorf("failed to resolve workflow file: %w", err)
}

// Check if it's a lock file or markdown file
lockFile := workflowFile
if !strings.HasSuffix(workflowFile, ".lock.yml") {
// Find corresponding lock file
baseName := strings.TrimSuffix(filepath.Base(workflowFile), ".md")
lockFile = filepath.Join(getWorkflowsDir(), baseName+".lock.yml")

// Check if lock file exists
if _, err := os.Stat(lockFile); os.IsNotExist(err) {
return fmt.Errorf("compiled workflow file not found: %s. Run 'gh aw compile %s' first", lockFile, workflowName)
}
}

if verbose {
fmt.Println(console.FormatInfoMessage(fmt.Sprintf("Testing workflow file: %s", lockFile)))
}

// Build act command
args := []string{"act"}

// Add event type
if event != "" {
args = append(args, event)
}

// Add workflow file
args = append(args, "--workflows", lockFile)

// Add verbose flag if requested
if verbose {
args = append(args, "--verbose")
}

if verbose {
fmt.Println(console.FormatCommandMessage(fmt.Sprintf("gh %s", strings.Join(args, " "))))
}

// Execute act command via GitHub CLI
cmd := exec.Command("gh", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin

if err := cmd.Run(); err != nil {
return fmt.Errorf("act execution failed: %w", err)
}

return nil
}

// CompileWorkflowForTesting compiles a single workflow with safe-outputs forced to staged mode
func CompileWorkflowForTesting(workflowName string, verbose bool) error {
// Create compiler with forced staged mode for testing
compiler := workflow.NewCompiler(verbose, "", GetVersion())
compiler.SetSkipValidation(false) // Enable validation for testing
compiler.SetForceStaged(true) // Force safe-outputs to be staged for local testing

// Resolve workflow file
workflowFile, err := resolveWorkflowFile(workflowName, verbose)
if err != nil {
return fmt.Errorf("failed to resolve workflow file: %w", err)
}

if verbose {
fmt.Println(console.FormatInfoMessage(fmt.Sprintf("Compiling workflow: %s", workflowFile)))
}

// Compile the workflow
err = compiler.CompileWorkflow(workflowFile)
if err != nil {
return fmt.Errorf("failed to compile workflow '%s': %w", workflowFile, err)
}

return nil
}
Loading
Loading