Skip to content

Commit a31595d

Browse files
committed
Add integration test infrastructure
1 parent f0148cd commit a31595d

File tree

10 files changed

+659
-0
lines changed

10 files changed

+659
-0
lines changed

.github/workflows/ci.yml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,4 +127,42 @@ jobs:
127127
echo "=== Total Coverage ==="
128128
go tool cover -func=coverage.out | grep total
129129
130+
integration-test:
131+
name: Integration Tests
132+
runs-on: ubuntu-latest
133+
134+
steps:
135+
- name: Checkout code
136+
uses: actions/checkout@v5
137+
138+
- name: Set up Go
139+
uses: actions/setup-go@v6
140+
with:
141+
go-version: '1.23'
142+
cache: true
143+
144+
- name: Set up Java (for test fixtures)
145+
uses: actions/setup-java@v5
146+
with:
147+
distribution: 'temurin'
148+
java-version: '11'
149+
150+
- name: Set up Docker Buildx
151+
uses: docker/setup-buildx-action@v3
152+
153+
- name: Setup test fixtures
154+
run: ./test/scripts/setup-fixtures.sh
155+
156+
- name: Run integration tests
157+
run: make test-integration
158+
159+
- name: Upload test artifacts on failure
160+
if: failure()
161+
uses: actions/upload-artifact@v4
162+
with:
163+
name: integration-test-artifacts
164+
path: |
165+
test/integration/*.log
166+
yc-*.zip
167+
130168

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,11 @@ config/config.go.bak
55
scripts/bak.run_yc_360.ps1
66
yc-*
77
yc
8+
9+
# Test fixtures (generated by test/scripts/setup-fixtures.sh)
10+
test/fixtures/*.jar
11+
test/fixtures/*.zip
12+
test/fixtures/*.class
13+
test/fixtures/launch.sh
14+
test/fixtures/launch.bat
15+
test/fixtures/lib/

Dockerfile.test

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
FROM golang:1.23-alpine
2+
3+
RUN apk add --no-cache \
4+
gcc musl-dev \
5+
ncurses-dev ncurses-static \
6+
openjdk11-jre \
7+
docker-cli \
8+
bash \
9+
curl \
10+
jq
11+
12+
WORKDIR /workspace
13+
14+
COPY go.mod go.sum ./
15+
RUN go mod download
16+
17+
ENTRYPOINT ["/bin/bash"]

Makefile

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,30 @@ shell:
2020

2121
build:
2222
docker exec -it yc-360-script-alpine /bin/sh -c "cd cmd/yc && go build -o yc -ldflags='-s -w' -buildvcs=false && mkdir -p ../../bin/ && mv yc ../../bin/"
23+
24+
# Integration tests (requires Docker)
25+
.PHONY: test-integration
26+
test-integration:
27+
@echo "Setting up test fixtures..."
28+
./test/scripts/setup-fixtures.sh
29+
@echo "Starting test environment..."
30+
docker compose -f docker-compose.test.yml up -d --build
31+
@echo "Waiting for services to be healthy..."
32+
sleep 10
33+
@echo "Running integration tests..."
34+
docker compose -f docker-compose.test.yml exec -T yc-test-runner \
35+
go test -v -tags=integration ./test/integration/... -timeout=5m
36+
@echo "Stopping test environment..."
37+
docker compose -f docker-compose.test.yml down -v
38+
39+
.PHONY: test-integration-local
40+
test-integration-local:
41+
@echo "Running integration tests locally (requires BuggyApp running)..."
42+
go test -v -tags=integration ./test/integration/... -timeout=5m
43+
44+
.PHONY: test
45+
test:
46+
go test -v -race -coverprofile=coverage.out -covermode=atomic ./...
47+
48+
.PHONY: test-all
49+
test-all: test test-integration

docker-compose.test.yml

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
services:
2+
# Test Java application (BuggyApp as fixture)
3+
buggyapp:
4+
image: openjdk:11.0.16-jdk
5+
container_name: yc-test-buggyapp
6+
volumes:
7+
- ./test/fixtures:/fixtures
8+
working_dir: /fixtures
9+
entrypoint: ["/bin/bash", "-c"]
10+
command:
11+
- |
12+
set -e
13+
# Install procps for pgrep (needed by healthcheck)
14+
apt-get update -qq && apt-get install -y -qq procps > /dev/null 2>&1
15+
# Run buggyapp as web application in foreground with GC logging
16+
exec java -Xmx2g -DlogDir=. -DuploadDir=. -XX:+PrintGCDetails -Xloggc:/tmp/gc.log -jar webapp-runner.jar --port 9010 buggyapp.war
17+
healthcheck:
18+
test: ["CMD-SHELL", "pgrep -f 'java.*webapp-runner' || exit 1"]
19+
interval: 5s
20+
timeout: 3s
21+
retries: 10
22+
start_period: 10s
23+
24+
# Mock yCrash server for upload testing
25+
mock-server:
26+
image: golang:1.23-alpine
27+
container_name: yc-test-server
28+
volumes:
29+
- ./test/mock-server:/app
30+
working_dir: /app
31+
command: go run main.go
32+
ports:
33+
- "8080:8080"
34+
healthcheck:
35+
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/health"]
36+
interval: 5s
37+
timeout: 3s
38+
retries: 5
39+
40+
# Test runner environment
41+
yc-test-runner:
42+
build:
43+
context: .
44+
dockerfile: Dockerfile.test
45+
container_name: yc-test-runner
46+
volumes:
47+
- .:/workspace
48+
# SECURITY NOTE: Docker socket access required for 'docker exec' commands
49+
# to retrieve PID from buggyapp container. This grants full Docker daemon access.
50+
# Alternative approach: Run yc binary directly in buggyapp container or use
51+
# HTTP-based process discovery to avoid socket mounting.
52+
- /var/run/docker.sock:/var/run/docker.sock
53+
working_dir: /workspace
54+
depends_on:
55+
buggyapp:
56+
condition: service_healthy
57+
mock-server:
58+
condition: service_healthy
59+
environment:
60+
BUGGYAPP_HOST: buggyapp
61+
MOCK_SERVER_URL: http://mock-server:8080
62+
command: ["-c", "sleep infinity"] # Keep container running for exec commands

test/integration/capture_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
//go:build integration
2+
3+
package integration
4+
5+
import (
6+
"fmt"
7+
"os"
8+
"os/exec"
9+
"path/filepath"
10+
"strings"
11+
"testing"
12+
)
13+
14+
func TestCapture_ThreadDump(t *testing.T) {
15+
t.Skip("Skipping for now")
16+
pid, cleanup := StartTestJVM(t)
17+
defer cleanup()
18+
19+
ycBin := BuildYCBinary(t)
20+
workDir := t.TempDir()
21+
22+
cmd := exec.Command(ycBin,
23+
"-onlyCapture",
24+
"-d=false", // Keep artifacts directory
25+
"-p", fmt.Sprintf("%d", pid),
26+
"-j", JavaHome,
27+
"-a", "test",
28+
)
29+
cmd.Dir = workDir
30+
31+
output, err := cmd.CombinedOutput()
32+
if err != nil {
33+
t.Fatalf("yc failed: %v\n%s", err, output)
34+
}
35+
36+
// Find the created directory (yc-YYYY-MM-DDTHH-mm-ss format)
37+
entries, err := os.ReadDir(workDir)
38+
if err != nil {
39+
t.Fatalf("Failed to read work directory: %v", err)
40+
}
41+
42+
var captureDir string
43+
for _, entry := range entries {
44+
if entry.IsDir() && strings.HasPrefix(entry.Name(), "yc-") {
45+
captureDir = filepath.Join(workDir, entry.Name())
46+
break
47+
}
48+
}
49+
50+
if captureDir == "" {
51+
t.Fatal("Could not find capture directory")
52+
}
53+
54+
// Verify threaddump.out contains expected content
55+
td, err := os.ReadFile(filepath.Join(captureDir, "threaddump.out"))
56+
if err != nil {
57+
t.Fatalf("Failed to read threaddump.out: %v", err)
58+
}
59+
60+
content := string(td)
61+
if !strings.Contains(content, "Full thread dump") {
62+
t.Error("Thread dump missing expected header")
63+
}
64+
if !strings.Contains(content, "java.lang.Thread.State") {
65+
t.Error("Thread dump missing thread state info")
66+
}
67+
}
68+
69+
func TestCapture_GCLog(t *testing.T) {
70+
pid, cleanup := StartTestJVM(t)
71+
defer cleanup()
72+
73+
ycBin := BuildYCBinary(t)
74+
workDir := t.TempDir()
75+
76+
cmd := exec.Command(ycBin,
77+
"-onlyCapture",
78+
"-d=false", // Keep artifacts directory
79+
"-p", fmt.Sprintf("%d", pid),
80+
"-j", JavaHome,
81+
"-a", "test",
82+
)
83+
cmd.Dir = workDir
84+
85+
output, err := cmd.CombinedOutput()
86+
if err != nil {
87+
t.Fatalf("yc failed: %v\n%s", err, output)
88+
}
89+
90+
// Find the created directory
91+
entries, err := os.ReadDir(workDir)
92+
if err != nil {
93+
t.Fatalf("Failed to read work directory: %v", err)
94+
}
95+
96+
var captureDir string
97+
for _, entry := range entries {
98+
if entry.IsDir() && strings.HasPrefix(entry.Name(), "yc-") {
99+
captureDir = filepath.Join(workDir, entry.Name())
100+
break
101+
}
102+
}
103+
104+
if captureDir == "" {
105+
t.Fatal("Could not find capture directory")
106+
}
107+
108+
// Verify gc.log exists and has content
109+
gc, err := os.ReadFile(filepath.Join(captureDir, "gc.log"))
110+
if err != nil {
111+
t.Fatalf("Failed to read gc.log: %v", err)
112+
}
113+
114+
if len(gc) == 0 {
115+
t.Error("GC log is empty")
116+
}
117+
}

test/integration/helpers.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
//go:build integration
2+
3+
package integration
4+
5+
import (
6+
"context"
7+
"fmt"
8+
"os"
9+
"os/exec"
10+
"path/filepath"
11+
"testing"
12+
"time"
13+
)
14+
15+
const (
16+
BuggyAppHost = "buggyapp"
17+
MockServerURL = "http://mock-server:8080"
18+
defaultJavaHome = "/usr/lib/jvm/java-11-openjdk"
19+
TestTimeout = 120 * time.Second
20+
)
21+
22+
var (
23+
// JavaHome is the path to Java installation, configurable via JAVA_HOME environment variable
24+
JavaHome = getJavaHome()
25+
)
26+
27+
// getJavaHome returns the Java home directory from environment or default
28+
func getJavaHome() string {
29+
if javaHome := os.Getenv("JAVA_HOME"); javaHome != "" {
30+
return javaHome
31+
}
32+
return defaultJavaHome
33+
}
34+
35+
// StartTestJVM launches a Java process for testing
36+
// Note: This function expects to run inside the Docker test environment
37+
// where it can communicate with the buggyapp container via the shared network
38+
func StartTestJVM(t *testing.T) (pid int, cleanup func()) {
39+
t.Helper()
40+
41+
// When running in the test runner container, we need to get the PID
42+
// from within the buggyapp container. We use 'docker exec' for this.
43+
// Note: This requires Docker socket access OR we could use HTTP-based
44+
// process discovery if buggyapp exposed a /pid endpoint.
45+
cmd := exec.Command("docker", "exec", "yc-test-buggyapp",
46+
"sh", "-c", "pgrep -f 'java.*buggyapp'")
47+
out, err := cmd.Output()
48+
if err != nil {
49+
t.Fatalf("Failed to get Java PID: %v", err)
50+
}
51+
52+
_, err = fmt.Sscanf(string(out), "%d", &pid)
53+
if err != nil {
54+
t.Fatalf("Failed to parse PID: %v", err)
55+
}
56+
57+
// Verify the process is still running
58+
cleanup = func() {
59+
// Optionally verify process is still running after test
60+
cmd := exec.Command("docker", "exec", "yc-test-buggyapp",
61+
"sh", "-c", fmt.Sprintf("kill -0 %d 2>/dev/null", pid))
62+
if err := cmd.Run(); err != nil {
63+
t.Logf("Warning: Java process %d is no longer running", pid)
64+
}
65+
}
66+
67+
return pid, cleanup
68+
}
69+
70+
// BuildYCBinary compiles the yc binary for testing
71+
func BuildYCBinary(t *testing.T) string {
72+
t.Helper()
73+
74+
binPath := filepath.Join(t.TempDir(), "yc")
75+
cmd := exec.Command("go", "build",
76+
"-o", binPath,
77+
"-ldflags", "-s -w",
78+
"/workspace/cmd/yc")
79+
80+
if out, err := cmd.CombinedOutput(); err != nil {
81+
t.Fatalf("Build failed: %v\n%s", err, out)
82+
}
83+
84+
// Verify binary was created and is executable
85+
info, err := os.Stat(binPath)
86+
if err != nil {
87+
t.Fatalf("Binary not created at %s: %v", binPath, err)
88+
}
89+
if info.Mode()&0111 == 0 {
90+
t.Fatalf("Binary at %s is not executable (mode: %v)", binPath, info.Mode())
91+
}
92+
93+
return binPath
94+
}
95+
96+
// WaitForFile polls for file existence
97+
func WaitForFile(path string, timeout time.Duration) error {
98+
ctx, cancel := context.WithTimeout(context.Background(), timeout)
99+
defer cancel()
100+
101+
ticker := time.NewTicker(500 * time.Millisecond)
102+
defer ticker.Stop()
103+
104+
for {
105+
select {
106+
case <-ctx.Done():
107+
return fmt.Errorf("timeout waiting for %s", path)
108+
case <-ticker.C:
109+
if _, err := os.Stat(path); err == nil {
110+
return nil
111+
}
112+
}
113+
}
114+
}

0 commit comments

Comments
 (0)