Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
44 changes: 44 additions & 0 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: "Integration Tests"

on:
push:
branches:
- 'main'
workflow_dispatch:

permissions:
contents: read
Comment thread
pelikhan marked this conversation as resolved.
models: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
integration:
runs-on: ubuntu-latest
env:
GOPROXY: https://proxy.golang.org/,direct
GOPRIVATE: ""
GONOPROXY: ""
GONOSUMDB: github.com/github/*
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version: ">=1.22"
check-latest: true

- name: Build gh-models binary
run: make build

- name: Run integration tests (without auth)
Comment thread
pelikhan marked this conversation as resolved.
Outdated
working-directory: integration
run: |
go mod tidy
go test -v -timeout=5m
env:
# Explicitly unset any GitHub tokens to test unauthenticated behavior
GITHUB_TOKEN: ""
GH_TOKEN: ""
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@
/gh-models-linux-*
/gh-models-windows-*
/gh-models-android-*

# Integration test dependencies
integration/go.sum
15 changes: 15 additions & 0 deletions DEV.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,21 @@ make vet # to find suspicious constructs
make tidy # to keep dependencies up-to-date
```

### Integration Tests

In addition to unit tests, we have integration tests that use the compiled binary to test against live endpoints:

```shell
# Build the binary first
make build

# Run integration tests
cd integration
go test -v
Comment thread
pelikhan marked this conversation as resolved.
```

Integration tests are located in the `integration/` directory and automatically skip tests requiring authentication when no GitHub token is available. See `integration/README.md` for more details.

## Releasing

When upgrading or installing the extension using `gh extension upgrade github/gh-models` or
Expand Down
5 changes: 5 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
check: fmt vet tidy test
.PHONY: check

build:
@echo "==> building gh-models binary <=="
script/build
.PHONY: build

fmt:
@echo "==> running Go format <=="
gofmt -s -l -w .
Expand Down
76 changes: 76 additions & 0 deletions integration/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Integration Tests

This directory contains integration tests for the `gh-models` CLI extension. These tests are separate from the unit tests and use the compiled binary to test actual functionality.

## Overview

The integration tests:
- Use the compiled `gh-models` binary (not mocked clients)
- Test basic functionality of each command (`list`, `run`, `view`, `eval`)
- Are designed to work with or without GitHub authentication
- Skip tests requiring live endpoints when authentication is unavailable
- Keep assertions minimal to avoid brittleness

## Running the Tests

### Prerequisites

1. Build the `gh-models` binary:
```bash
cd ..
script/build
```

2. (Optional) Authenticate with GitHub CLI for full testing:
```bash
gh auth login
```

### Running Locally

From the integration directory:
```bash
go test -v
```

Without authentication, some tests will be skipped:
```
=== RUN TestIntegrationHelp
--- PASS: TestIntegrationHelp (0.05s)
=== RUN TestIntegrationList
integration_test.go:90: Skipping integration test - no GitHub authentication available
--- SKIP: TestIntegrationList (0.04s)
```

With authentication, all tests should run and test live endpoints.

## CI/CD

The integration tests run automatically on pushes to `main` via the GitHub Actions workflow `.github/workflows/integration.yml`.

The workflow:
1. Builds the binary
2. Runs tests without authentication (tests basic functionality)
3. On manual dispatch, can also run with authentication for full testing

## Test Structure

Each test follows this pattern:
- Check for binary existence (skip if not built)
- Check for authentication (skip live endpoint tests if unavailable)
- Execute the binary with specific arguments
- Verify basic output format and success/failure

Tests are intentionally simple and focus on:
- Commands execute without errors
- Help text is present and correctly formatted
- Basic output format is as expected
- Authentication requirements are respected

## Adding New Tests

When adding new commands or features:
1. Add a corresponding integration test
2. Follow the existing pattern of checking authentication
3. Keep assertions minimal but meaningful
4. Ensure tests work both with and without authentication
11 changes: 11 additions & 0 deletions integration/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module github.com/github/gh-models/integration

go 1.22

require github.com/stretchr/testify v1.10.0

require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
221 changes: 221 additions & 0 deletions integration/integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
package integration

import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"

"github.com/stretchr/testify/require"
)

const (
binaryName = "gh-models"
timeoutDuration = 30 * time.Second
)

// getBinaryPath returns the path to the compiled gh-models binary
func getBinaryPath(t *testing.T) string {
wd, err := os.Getwd()
require.NoError(t, err)

// Binary should be in the parent directory
binaryPath := filepath.Join(filepath.Dir(wd), binaryName)

// Check if binary exists
if _, err := os.Stat(binaryPath); os.IsNotExist(err) {
t.Skipf("Binary %s not found. Run 'script/build' first.", binaryPath)
}

return binaryPath
}

// hasAuthToken checks if GitHub authentication is available
func hasAuthToken() bool {
// Check if gh CLI is available and authenticated
cmd := exec.Command("gh", "auth", "status")
return cmd.Run() == nil
}

// runCommand executes the gh-models binary with given arguments
func runCommand(t *testing.T, args ...string) (stdout, stderr string, err error) {
binaryPath := getBinaryPath(t)

cmd := exec.Command(binaryPath, args...)
cmd.Env = os.Environ()

// Set timeout
done := make(chan error, 1)
var stdoutBytes, stderrBytes []byte

go func() {
stdoutBytes, err = cmd.Output()
if err != nil {
if exitError, ok := err.(*exec.ExitError); ok {
stderrBytes = exitError.Stderr
}
}
done <- err
}()

select {
case err = <-done:
return string(stdoutBytes), string(stderrBytes), err
case <-time.After(timeoutDuration):
if cmd.Process != nil {
cmd.Process.Kill()
}
t.Fatalf("Command timed out after %v", timeoutDuration)
return "", "", nil
}
}

func TestIntegrationHelp(t *testing.T) {
stdout, stderr, err := runCommand(t, "--help")

// Help should always work, even without auth
require.NoError(t, err, "stderr: %s", stderr)
require.Contains(t, stdout, "GitHub Models CLI extension")
require.Contains(t, stdout, "Available Commands:")
require.Contains(t, stdout, "list")
require.Contains(t, stdout, "run")
require.Contains(t, stdout, "view")
require.Contains(t, stdout, "eval")
}

func TestIntegrationList(t *testing.T) {
if !hasAuthToken() {
t.Skip("Skipping integration test - no GitHub authentication available")
}

stdout, stderr, err := runCommand(t, "list")

if err != nil {
t.Logf("List command failed. stdout: %s, stderr: %s", stdout, stderr)
// If the command fails due to auth issues, skip the test
if strings.Contains(stderr, "authentication") || strings.Contains(stderr, "token") {
t.Skip("Skipping - authentication issue")
}
require.NoError(t, err, "List command should succeed with valid auth")
}

// Basic verification that list command produces expected output format
require.NotEmpty(t, stdout, "List should produce output")
// Should contain some indication of models or table headers
lowerOut := strings.ToLower(stdout)
hasExpectedContent := strings.Contains(lowerOut, "model") ||
strings.Contains(lowerOut, "name") ||
strings.Contains(lowerOut, "id") ||
strings.Contains(lowerOut, "display")
require.True(t, hasExpectedContent, "List output should contain model information")
}

func TestIntegrationListHelp(t *testing.T) {
stdout, stderr, err := runCommand(t, "list", "--help")

require.NoError(t, err, "stderr: %s", stderr)
require.Contains(t, stdout, "Returns a list of models")
require.Contains(t, stdout, "Usage:")
}

func TestIntegrationView(t *testing.T) {
if !hasAuthToken() {
t.Skip("Skipping integration test - no GitHub authentication available")
}

// First get a model to view
listOut, _, listErr := runCommand(t, "list")
if listErr != nil {
t.Skip("Cannot run view test - list command failed")
}

// Extract a model name from list output (this is basic parsing)
lines := strings.Split(listOut, "\n")
var modelName string
for _, line := range lines {
line = strings.TrimSpace(line)
// Look for lines that might contain model IDs (containing forward slash)
if strings.Contains(line, "/") && !strings.HasPrefix(line, "Usage:") &&
!strings.HasPrefix(line, "gh models") && line != "" {
// Try to extract what looks like a model ID
fields := strings.Fields(line)
for _, field := range fields {
if strings.Contains(field, "/") {
modelName = field
break
}
}
if modelName != "" {
break
}
}
}

if modelName == "" {
t.Skip("Could not extract model name from list output")
}

stdout, stderr, err := runCommand(t, "view", modelName)

if err != nil {
t.Logf("View command failed for model %s. stdout: %s, stderr: %s", modelName, stdout, stderr)
// If the command fails due to auth issues, skip the test
if strings.Contains(stderr, "authentication") || strings.Contains(stderr, "token") {
t.Skip("Skipping - authentication issue")
}
require.NoError(t, err, "View command should succeed with valid model")
}

// Basic verification that view command produces expected output
require.NotEmpty(t, stdout, "View should produce output")
lowerOut := strings.ToLower(stdout)
hasExpectedContent := strings.Contains(lowerOut, "model") ||
strings.Contains(lowerOut, "name") ||
strings.Contains(lowerOut, "description") ||
strings.Contains(lowerOut, "publisher")
require.True(t, hasExpectedContent, "View output should contain model details")
}

func TestIntegrationViewHelp(t *testing.T) {
stdout, stderr, err := runCommand(t, "view", "--help")

require.NoError(t, err, "stderr: %s", stderr)
require.Contains(t, stdout, "Returns details about the specified model")
require.Contains(t, stdout, "Usage:")
}

func TestIntegrationRunHelp(t *testing.T) {
stdout, stderr, err := runCommand(t, "run", "--help")

require.NoError(t, err, "stderr: %s", stderr)
require.Contains(t, stdout, "Prompts the specified model")
require.Contains(t, stdout, "Usage:")
}

func TestIntegrationEvalHelp(t *testing.T) {
stdout, stderr, err := runCommand(t, "eval", "--help")

require.NoError(t, err, "stderr: %s", stderr)
require.Contains(t, stdout, "Runs evaluation tests against a model")
require.Contains(t, stdout, "Usage:")
}

// TestIntegrationRun tests the run command with a simple prompt
// This test is more limited since it requires actual model inference
func TestIntegrationRun(t *testing.T) {
if !hasAuthToken() {
t.Skip("Skipping integration test - no GitHub authentication available")
}

// We'll test with a very simple prompt to minimize cost and time
// Using a basic model and short prompt
stdout, _, err := runCommand(t, "run", "--help")
require.NoError(t, err, "Run help should work")

// For now, just verify the help works.
// A full test would require setting up a prompt and model,
// which might be expensive for CI
require.Contains(t, stdout, "Prompts the specified model")
}