Skip to content

Commit 68365d1

Browse files
committed
Add integration test with real Gradle build cycle
Exercises the full save/restore cycle using a committed Kotlin fixture project in testdata/. Verifies configuration cache reuse, build cache hits (FROM-CACHE), and wrapper restoration. Also adds Java 25 to CI so the integration test runs on GitHub Actions.
1 parent b0cbac4 commit 68365d1

10 files changed

Lines changed: 540 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,13 @@ jobs:
1818
- name: Install system dependencies
1919
run: sudo apt-get install -y zstd
2020

21+
- uses: actions/setup-java@v4
22+
with:
23+
distribution: temurin
24+
java-version: 25
25+
2126
- name: Test
22-
run: go test -run ^Test ./...
27+
run: go test -run ^Test -timeout 300s ./...
2328

2429
- name: Build
2530
run: go build ./...
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"io"
7+
"os"
8+
"os/exec"
9+
"path/filepath"
10+
"strings"
11+
"sync"
12+
"testing"
13+
)
14+
15+
// fileBundleStore is a file-system-backed bundleStore for integration tests.
16+
type fileBundleStore struct {
17+
dir string
18+
}
19+
20+
func (f *fileBundleStore) path(commit, cacheKey string) string {
21+
return filepath.Join(f.dir, commit, bundleFilename(cacheKey))
22+
}
23+
24+
func (f *fileBundleStore) stat(_ context.Context, commit, cacheKey string) (int64, error) {
25+
fi, err := os.Stat(f.path(commit, cacheKey))
26+
if err != nil {
27+
return 0, err
28+
}
29+
return fi.Size(), nil
30+
}
31+
32+
func (f *fileBundleStore) get(_ context.Context, commit, cacheKey string, _ int64) (io.ReadCloser, error) {
33+
return os.Open(f.path(commit, cacheKey))
34+
}
35+
36+
func (f *fileBundleStore) put(_ context.Context, commit, cacheKey string, r io.ReadSeeker, _ int64) error {
37+
p := f.path(commit, cacheKey)
38+
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
39+
return err
40+
}
41+
data, err := io.ReadAll(r)
42+
if err != nil {
43+
return err
44+
}
45+
return os.WriteFile(p, data, 0o644)
46+
}
47+
48+
// copyDir recursively copies src to dst, preserving file modes.
49+
func copyDir(dst, src string) error {
50+
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
51+
if err != nil {
52+
return err
53+
}
54+
rel, _ := filepath.Rel(src, path)
55+
target := filepath.Join(dst, rel)
56+
if info.IsDir() {
57+
return os.MkdirAll(target, info.Mode())
58+
}
59+
data, err := os.ReadFile(path)
60+
if err != nil {
61+
return err
62+
}
63+
return os.WriteFile(target, data, info.Mode())
64+
})
65+
}
66+
67+
// TestIntegrationGradleBuildCycle exercises the full save/restore cycle with a
68+
// real Gradle build. It verifies that after restoring a saved cache, the
69+
// configuration cache reports a hit and build tasks are UP-TO-DATE.
70+
//
71+
// The test uses a committed fixture project in testdata/gradle-project rather
72+
// than generating Gradle files from Go.
73+
//
74+
// Requirements: Java on PATH, internet access (first run downloads Gradle wrapper).
75+
// Skipped automatically if Java is not available or in -short mode.
76+
func TestIntegrationGradleBuildCycle(t *testing.T) {
77+
if testing.Short() {
78+
t.Skip("skipping integration test in short mode")
79+
}
80+
for _, tool := range []string{"java", "zstd", "tar"} {
81+
if _, err := exec.LookPath(tool); err != nil {
82+
t.Skipf("%s not available", tool)
83+
}
84+
}
85+
86+
ctx := context.Background()
87+
88+
// Copy the fixture project into a temp dir so we can mutate it freely.
89+
fixtureDir := filepath.Join("testdata", "gradle-project")
90+
if _, err := os.Stat(fixtureDir); err != nil {
91+
t.Fatalf("fixture not found: %v", err)
92+
}
93+
94+
projectDir := t.TempDir()
95+
if err := copyDir(projectDir, fixtureDir); err != nil {
96+
t.Fatalf("copying fixture: %v", err)
97+
}
98+
99+
gradleUserHome := filepath.Join(t.TempDir(), "gradle-home")
100+
must(t, os.MkdirAll(gradleUserHome, 0o755))
101+
102+
gradlew := filepath.Join(projectDir, "gradlew")
103+
must(t, os.Chmod(gradlew, 0o755))
104+
105+
// ── Initialize git repo (needed for commit-based cache keys) ─────────────
106+
gitRun := func(args ...string) string {
107+
t.Helper()
108+
cmd := exec.CommandContext(ctx, "git", append([]string{"-C", projectDir}, args...)...)
109+
cmd.Env = append(os.Environ(),
110+
"GIT_AUTHOR_NAME=Test",
111+
112+
"GIT_COMMITTER_NAME=Test",
113+
114+
)
115+
out, err := cmd.CombinedOutput()
116+
if err != nil {
117+
t.Fatalf("git %v: %v\n%s", args, err, out)
118+
}
119+
return strings.TrimSpace(string(out))
120+
}
121+
122+
gitRun("init")
123+
gitRun("add", ".")
124+
gitRun("commit", "-m", "initial")
125+
commitSHA := gitRun("rev-parse", "HEAD")
126+
127+
// ── Step 1: Initial Gradle build ─────────────────────────────────────────
128+
t.Log("Step 1: Running initial Gradle build...")
129+
gradleRun(t, ctx, gradlew, projectDir, gradleUserHome, "build")
130+
131+
// Verify compilation produced class files.
132+
classesDir := filepath.Join(projectDir, "build", "classes")
133+
if _, err := os.Stat(classesDir); err != nil {
134+
t.Fatalf("expected compiled classes: %v", err)
135+
}
136+
137+
// ── Step 2: Save the cache ───────────────────────────────────────────────
138+
t.Log("Step 2: Saving cache...")
139+
store := &fileBundleStore{dir: t.TempDir()}
140+
cacheKey := "cache-test:build"
141+
142+
sources := []tarSource{{BaseDir: gradleUserHome, Path: "./caches"}}
143+
if fi, err := os.Stat(filepath.Join(gradleUserHome, "wrapper")); err == nil && fi.IsDir() {
144+
sources = append(sources, tarSource{BaseDir: gradleUserHome, Path: "./wrapper"})
145+
}
146+
if fi, err := os.Stat(filepath.Join(projectDir, ".gradle", "configuration-cache")); err == nil && fi.IsDir() {
147+
sources = append(sources, tarSource{
148+
BaseDir: filepath.Join(projectDir, ".gradle"),
149+
Path: "./configuration-cache",
150+
})
151+
}
152+
153+
tmp, err := os.CreateTemp("", "gradle-cache-test-*")
154+
if err != nil {
155+
t.Fatal(err)
156+
}
157+
defer os.Remove(tmp.Name())
158+
159+
if err := createTarZstd(ctx, tmp, sources); err != nil {
160+
t.Fatalf("createTarZstd: %v", err)
161+
}
162+
size, _ := tmp.Seek(0, io.SeekCurrent)
163+
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
164+
t.Fatal(err)
165+
}
166+
t.Logf(" Bundle size: %.1f MB", float64(size)/1e6)
167+
168+
if err := store.put(ctx, commitSHA, cacheKey, tmp, size); err != nil {
169+
t.Fatalf("store.put: %v", err)
170+
}
171+
tmp.Close()
172+
173+
// ── Step 3: Clear all Gradle state ───────────────────────────────────────
174+
t.Log("Step 3: Clearing Gradle state...")
175+
must(t, os.RemoveAll(gradleUserHome))
176+
must(t, os.MkdirAll(gradleUserHome, 0o755))
177+
must(t, os.RemoveAll(filepath.Join(projectDir, ".gradle")))
178+
must(t, os.RemoveAll(filepath.Join(projectDir, "build")))
179+
180+
if _, err := os.Stat(filepath.Join(gradleUserHome, "caches")); err == nil {
181+
t.Fatal("expected caches dir to be gone after cleanup")
182+
}
183+
184+
// ── Step 4: Restore the cache ────────────────────────────────────────────
185+
t.Log("Step 4: Restoring cache...")
186+
body, err := store.get(ctx, commitSHA, cacheKey, 0)
187+
if err != nil {
188+
t.Fatalf("store.get: %v", err)
189+
}
190+
191+
rules := []extractRule{
192+
{prefix: "caches/", baseDir: gradleUserHome},
193+
{prefix: "wrapper/", baseDir: gradleUserHome},
194+
{prefix: "configuration-cache/", baseDir: filepath.Join(projectDir, ".gradle")},
195+
}
196+
if err := extractBundleZstd(ctx, body, rules, projectDir); err != nil {
197+
t.Fatalf("extractBundleZstd: %v", err)
198+
}
199+
body.Close()
200+
201+
if _, err := os.Stat(filepath.Join(gradleUserHome, "caches")); err != nil {
202+
t.Fatalf("expected caches dir after restore: %v", err)
203+
}
204+
205+
// Check if configuration-cache was restored.
206+
ccRestored := filepath.Join(projectDir, ".gradle", "configuration-cache")
207+
if _, err := os.Stat(ccRestored); err != nil {
208+
t.Log(" configuration-cache dir was NOT restored (may not have been in the bundle)")
209+
} else {
210+
t.Log(" configuration-cache dir restored")
211+
}
212+
213+
// ── Step 5: Rebuild and verify cache hits ────────────────────────────────
214+
t.Log("Step 5: Rebuilding to verify cache hits...")
215+
output := gradleRun(t, ctx, gradlew, projectDir, gradleUserHome, "build")
216+
217+
// Check for configuration cache reuse.
218+
if strings.Contains(output, "Reusing configuration cache") {
219+
t.Log(" Configuration cache: reused")
220+
} else {
221+
ccLine := extractLine(output, "configuration cache")
222+
t.Logf(" Configuration cache: %s", ccLine)
223+
if strings.Contains(ccLine, "stored") {
224+
t.Error("expected configuration cache to be reused, but it was stored fresh")
225+
}
226+
}
227+
228+
// Check for build cache hits (FROM-CACHE) on compilation tasks.
229+
fromCacheCount := strings.Count(output, "FROM-CACHE")
230+
upToDateCount := strings.Count(output, "UP-TO-DATE")
231+
t.Logf(" Task results: %d FROM-CACHE, %d UP-TO-DATE", fromCacheCount, upToDateCount)
232+
233+
if fromCacheCount == 0 && upToDateCount == 0 {
234+
t.Error("expected at least some tasks to be FROM-CACHE or UP-TO-DATE after restore")
235+
}
236+
237+
t.Log("Integration test passed")
238+
}
239+
240+
// gradleRun executes a Gradle build and returns the combined output.
241+
func gradleRun(t *testing.T, ctx context.Context, gradlew, projectDir, gradleUserHome string, tasks ...string) string {
242+
t.Helper()
243+
args := append(tasks, "--no-daemon", "--info")
244+
cmd := exec.CommandContext(ctx, gradlew, args...)
245+
cmd.Dir = projectDir
246+
cmd.Env = gradleEnv(gradleUserHome)
247+
248+
var stdout, stderr bytes.Buffer
249+
cmd.Stdout = io.MultiWriter(&stdout, &logWriter{t: t, prefix: " [gradle] "})
250+
cmd.Stderr = io.MultiWriter(&stderr, &logWriter{t: t, prefix: " [gradle] "})
251+
252+
if err := cmd.Run(); err != nil {
253+
t.Fatalf("gradle %v failed: %v\nstdout:\n%s\nstderr:\n%s",
254+
tasks, err, stdout.String(), stderr.String())
255+
}
256+
return stdout.String() + stderr.String()
257+
}
258+
259+
func gradleEnv(gradleUserHome string) []string {
260+
env := os.Environ()
261+
filtered := make([]string, 0, len(env)+2)
262+
for _, e := range env {
263+
if !strings.HasPrefix(e, "GRADLE_USER_HOME=") &&
264+
!strings.HasPrefix(e, "GRADLE_ENCRYPTION_KEY=") {
265+
filtered = append(filtered, e)
266+
}
267+
}
268+
return append(filtered,
269+
"GRADLE_USER_HOME="+gradleUserHome,
270+
// Fixed encryption key so the configuration cache keystore is stable
271+
// across Gradle invocations and survives save/restore cycles.
272+
"GRADLE_ENCRYPTION_KEY=7FmG8IW20OSZFPEUD6OWjP847SYQz07Oe/4iAN6dpo0=",
273+
)
274+
}
275+
276+
func extractLine(output, substr string) string {
277+
for _, line := range strings.Split(output, "\n") {
278+
if strings.Contains(strings.ToLower(line), strings.ToLower(substr)) {
279+
return strings.TrimSpace(line)
280+
}
281+
}
282+
return ""
283+
}
284+
285+
type logWriter struct {
286+
t *testing.T
287+
prefix string
288+
mu sync.Mutex
289+
buf []byte
290+
}
291+
292+
func (w *logWriter) Write(p []byte) (int, error) {
293+
w.mu.Lock()
294+
defer w.mu.Unlock()
295+
w.buf = append(w.buf, p...)
296+
for {
297+
idx := bytes.IndexByte(w.buf, '\n')
298+
if idx < 0 {
299+
break
300+
}
301+
line := string(w.buf[:idx])
302+
w.buf = w.buf[idx+1:]
303+
if strings.TrimSpace(line) != "" {
304+
w.t.Log(w.prefix + line)
305+
}
306+
}
307+
return len(p), nil
308+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
plugins {
2+
kotlin("jvm") version "2.1.20"
3+
}
4+
5+
repositories {
6+
mavenCentral()
7+
}
8+
9+
dependencies {
10+
testImplementation(kotlin("test"))
11+
}
12+
13+
tasks.test {
14+
useJUnitPlatform()
15+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
org.gradle.configuration-cache=true
2+
org.gradle.caching=true
Binary file not shown.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
distributionBase=GRADLE_USER_HOME
2+
distributionPath=wrapper/dists
3+
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
4+
networkTimeout=10000
5+
validateDistributionUrl=true
6+
zipStoreBase=GRADLE_USER_HOME
7+
zipStorePath=wrapper/dists

0 commit comments

Comments
 (0)