Skip to content

Commit 10b23bc

Browse files
authored
Stronger session isolation and faster E2E tests with parallelization (#374)
- Port selection in sessions now have an offset of 5 (per port selected) like `port = port + concurrent_sessions × 5` - Session directories are now isolated by their own names instead of defaulting to a `devnet` directory: - The main playground dir is still like `$HOME/.local/state/builder-playground` - The new session paths are like `<playground-dir>/sessions/<session-id>` - There are two new symlinks: - `<playground-dir>/devnet` (keeping old experience to access to the latest dir) - `<playground-dir>/sessions/latest` (new symlink for keeping reference to the latest dir) - The docker bridge networks used for each session is now isolated by name as well, like `builder-playground-<session-id>` - Creating new E2E test suite which can: - run the playground binary to start a session per test case (in parallel) - apply side effects to the services of sessions - make checks on the state of the running services - The component/integration tests are abandoned in favor of the more complete E2E tests With these changes, now it is also possible to start multiple sessions on a machine for testing and connect services of different sessions. Fixes #333
1 parent db477c5 commit 10b23bc

26 files changed

+979
-497
lines changed

.github/workflows/checks.yaml

Lines changed: 4 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,9 @@ on:
88
branches: [main]
99

1010
jobs:
11-
e2e-test:
12-
name: E2E test (${{ matrix.flags }})
11+
e2e-tests:
12+
name: E2E tests
1313
runs-on: warp-ubuntu-latest-x64-8x
14-
strategy:
15-
matrix:
16-
flags:
17-
- "l1"
18-
- "l1 --use-native-reth"
19-
- "l1 --with-prometheus"
20-
- "opstack"
21-
- "opstack --external-builder http://host.docker.internal:4444"
22-
- "opstack --enable-latest-fork=0"
23-
- "opstack --enable-latest-fork=10"
2414
steps:
2515
- name: Check out code
2616
uses: actions/checkout@v6
@@ -36,20 +26,8 @@ jobs:
3626
- name: Build playground utils
3727
run: ./scripts/ci-build-playground-utils.sh
3828

39-
- name: Run playground
40-
run: go run main.go start ${{ matrix.flags }} --output /tmp/playground --timeout 10s --watchdog
41-
42-
- name: Copy playground logs
43-
if: ${{ failure() }}
44-
run: ./scripts/ci-copy-playground-logs.sh /tmp/playground /tmp/playground-logs
45-
46-
- name: Archive playground logs
47-
uses: actions/upload-artifact@v4
48-
if: ${{ failure() }}
49-
with:
50-
name: playground-logs-${{ matrix.flags }}
51-
path: /tmp/playground-logs
52-
retention-days: 5
29+
- name: Run E2E tests
30+
run: make e2e-test
5331

5432
unit-test:
5533
name: Unit test
@@ -69,27 +47,6 @@ jobs:
6947
- name: Run unit tests
7048
run: go test -v ./playground/...
7149

72-
integration-test:
73-
name: Integration test
74-
runs-on: warp-ubuntu-latest-x64-8x
75-
steps:
76-
- name: Check out code
77-
uses: actions/checkout@v6
78-
79-
- name: Set up Go
80-
uses: actions/setup-go@v6
81-
with:
82-
go-version: 1.25
83-
84-
- name: Install docker compose
85-
run: ./scripts/ci-setup-docker-compose.sh
86-
87-
- name: Build playground utils
88-
run: ./scripts/ci-build-playground-utils.sh
89-
90-
- name: Run unit tests
91-
run: make integration-test
92-
9350
lint:
9451
name: Lint
9552
runs-on: warp-ubuntu-latest-x64-8x

Makefile

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@ build: ## Build the CLI
2727
test: ## Run tests
2828
go test -v -count=1 ./...
2929

30-
.PHONY: integration-test
31-
integration-test: ## Run integration tests
32-
INTEGRATION_TESTS=true go test -v -count=1 ./playground/... -run TestRecipe
33-
INTEGRATION_TESTS=true go test -v -count=1 ./playground/... -run TestComponent
30+
.PHONY: e2e-test
31+
e2e-test:
32+
go build .
33+
E2E_TESTS=true go test -v -count=1 ./e2e/...
3434

3535
.PHONY: generate-docs
3636
generate-docs: ## Auto-generate recipe docs

e2e/e2e_test.go

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
package e2e
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
"runtime"
10+
"strconv"
11+
"strings"
12+
"sync"
13+
"testing"
14+
"time"
15+
16+
"github.com/ethereum/go-ethereum/ethclient"
17+
"github.com/ethereum/go-ethereum/rpc"
18+
"github.com/flashbots/builder-playground/playground"
19+
"github.com/stretchr/testify/require"
20+
)
21+
22+
// startupMu ensures only one playground starts at a time
23+
var startupMu sync.Mutex
24+
25+
// lineBuffer captures output and allows checking for specific strings
26+
type lineBuffer struct {
27+
mu sync.Mutex
28+
lines []string
29+
}
30+
31+
func (b *lineBuffer) Write(p []byte) (n int, err error) {
32+
b.mu.Lock()
33+
defer b.mu.Unlock()
34+
b.lines = append(b.lines, string(p))
35+
return len(p), nil
36+
}
37+
38+
func (b *lineBuffer) String() string {
39+
b.mu.Lock()
40+
defer b.mu.Unlock()
41+
return strings.Join(b.lines, "")
42+
}
43+
44+
func (b *lineBuffer) Contains(s string) bool {
45+
b.mu.Lock()
46+
defer b.mu.Unlock()
47+
for _, line := range b.lines {
48+
if strings.Contains(line, s) {
49+
return true
50+
}
51+
}
52+
return false
53+
}
54+
55+
// playgroundInstance holds state for a single playground run
56+
type playgroundInstance struct {
57+
t *testing.T
58+
cmd *exec.Cmd
59+
outputDir string
60+
manifestPath string
61+
manifest *playground.Manifest
62+
manifestLoaded bool
63+
processCtx context.Context
64+
processCancel context.CancelFunc
65+
processErr error
66+
processErrMu sync.Mutex
67+
outputBuffer *lineBuffer
68+
}
69+
70+
func getRepoRoot() string {
71+
_, filename, _, _ := runtime.Caller(0)
72+
return filepath.Dir(filepath.Dir(filename))
73+
}
74+
75+
func getBinaryPath() string {
76+
return filepath.Join(getRepoRoot(), "builder-playground")
77+
}
78+
79+
func newPlaygroundInstance(t *testing.T) *playgroundInstance {
80+
if strings.ToLower(os.Getenv("E2E_TESTS")) != "true" {
81+
t.Skip("e2e tests not enabled")
82+
}
83+
t.Parallel()
84+
outputDir := t.TempDir()
85+
return &playgroundInstance{
86+
t: t,
87+
outputDir: outputDir,
88+
manifestPath: filepath.Join(outputDir, "manifest.json"),
89+
}
90+
}
91+
92+
func (p *playgroundInstance) cleanup() {
93+
// Dump buffered logs at the end of the test
94+
if p.outputBuffer != nil {
95+
p.t.Logf("=== Playground logs for %s ===\n%s", p.t.Name(), p.outputBuffer.String())
96+
}
97+
98+
if p.cmd != nil && p.cmd.Process != nil {
99+
p.cmd.Process.Signal(os.Interrupt)
100+
if p.processCtx != nil {
101+
select {
102+
case <-p.processCtx.Done():
103+
case <-time.After(10 * time.Second):
104+
p.cmd.Process.Kill()
105+
}
106+
}
107+
}
108+
if p.outputDir != "" {
109+
os.RemoveAll(p.outputDir)
110+
}
111+
}
112+
113+
func (p *playgroundInstance) launchPlayground(args []string) {
114+
startupMu.Lock()
115+
116+
cmdArgs := append([]string{"start"}, args...)
117+
cmdArgs = append(cmdArgs, "--output", p.outputDir)
118+
119+
cmd := exec.Command(getBinaryPath(), cmdArgs...)
120+
cmd.Dir = getRepoRoot()
121+
122+
p.outputBuffer = &lineBuffer{}
123+
cmd.Stdout = p.outputBuffer
124+
cmd.Stderr = p.outputBuffer
125+
126+
err := cmd.Start()
127+
require.NoError(p.t, err, "failed to start playground")
128+
129+
p.cmd = cmd
130+
131+
p.processCtx, p.processCancel = context.WithCancel(context.Background())
132+
go func() {
133+
err := cmd.Wait()
134+
p.processErrMu.Lock()
135+
p.processErr = err
136+
p.processErrMu.Unlock()
137+
p.processCancel()
138+
}()
139+
140+
// Wait until "Waiting for services to get healthy" appears - this means ports have been allocated
141+
p.waitForOutput("Waiting for services to get healthy", 60*time.Second)
142+
startupMu.Unlock()
143+
}
144+
145+
func (p *playgroundInstance) runPlayground(args ...string) {
146+
p.launchPlayground(append(args, "--timeout", "10s"))
147+
148+
// Wait for process to complete (it has --timeout so it will exit)
149+
<-p.processCtx.Done()
150+
require.NoError(p.t, p.getProcessErr(), "playground exited with error")
151+
}
152+
153+
func (p *playgroundInstance) getProcessErr() error {
154+
p.processErrMu.Lock()
155+
defer p.processErrMu.Unlock()
156+
return p.processErr
157+
}
158+
159+
func (p *playgroundInstance) startPlayground(args ...string) {
160+
p.launchPlayground(args)
161+
p.waitForReady()
162+
}
163+
164+
func (p *playgroundInstance) waitForOutput(message string, timeout time.Duration) {
165+
timeoutCh := time.After(timeout)
166+
ticker := time.NewTicker(100 * time.Millisecond)
167+
defer ticker.Stop()
168+
169+
for {
170+
select {
171+
case <-p.processCtx.Done():
172+
p.t.Fatalf("playground process exited before '%s': %v", message, p.getProcessErr())
173+
case <-timeoutCh:
174+
p.t.Fatalf("timeout waiting for '%s' message", message)
175+
case <-ticker.C:
176+
if p.outputBuffer.Contains(message) {
177+
p.t.Logf("Found message: %s", message)
178+
return
179+
}
180+
}
181+
}
182+
}
183+
184+
func (p *playgroundInstance) waitForReady() {
185+
p.t.Logf("Waiting for playground to be ready...")
186+
187+
ticker := time.NewTicker(500 * time.Millisecond)
188+
defer ticker.Stop()
189+
timeout := time.After(90 * time.Second)
190+
191+
for {
192+
select {
193+
case <-p.processCtx.Done():
194+
if err := p.getProcessErr(); err != nil {
195+
p.t.Fatalf("playground process exited with error: %v", err)
196+
}
197+
if !p.outputBuffer.Contains("All services are healthy") {
198+
p.t.Fatalf("playground process exited before services were ready")
199+
}
200+
return
201+
case <-timeout:
202+
p.t.Fatalf("timeout waiting for playground to be ready")
203+
case <-ticker.C:
204+
p.tryLoadManifest()
205+
if p.outputBuffer.Contains("All services are healthy") {
206+
p.t.Logf("Services are ready")
207+
return
208+
}
209+
}
210+
}
211+
}
212+
213+
func (p *playgroundInstance) tryLoadManifest() {
214+
if p.manifestLoaded {
215+
return
216+
}
217+
if _, err := os.Stat(p.manifestPath); err != nil {
218+
return
219+
}
220+
data, err := os.ReadFile(p.manifestPath)
221+
if err != nil || len(data) == 0 {
222+
return
223+
}
224+
var manifest playground.Manifest
225+
if err := json.Unmarshal(data, &manifest); err != nil {
226+
return
227+
}
228+
p.manifest = &manifest
229+
p.t.Logf("Manifest loaded with session ID: %s", manifest.ID)
230+
p.manifestLoaded = true
231+
}
232+
233+
func (p *playgroundInstance) getServicePort(serviceName, portName string) int {
234+
require.NotNil(p.t, p.manifest, "manifest not loaded")
235+
236+
var lastErr error
237+
for i := 0; i < 10; i++ {
238+
portStr, err := playground.GetServicePort(p.manifest.ID, serviceName, portName)
239+
if err == nil {
240+
port, err := strconv.Atoi(portStr)
241+
if err == nil {
242+
return port
243+
}
244+
lastErr = err
245+
} else {
246+
lastErr = err
247+
}
248+
time.Sleep(500 * time.Millisecond)
249+
}
250+
p.t.Fatalf("failed to get port %s on service %s: %v", portName, serviceName, lastErr)
251+
return 0
252+
}
253+
254+
func (p *playgroundInstance) waitForBlock(rpcURL string, targetBlock uint64) {
255+
rpcClient, err := rpc.Dial(rpcURL)
256+
require.NoError(p.t, err, "failed to dial RPC")
257+
defer rpcClient.Close()
258+
259+
clt := ethclient.NewClient(rpcClient)
260+
timeout := time.After(time.Minute)
261+
262+
for {
263+
select {
264+
case <-timeout:
265+
p.t.Fatalf("timeout waiting for block %d on %s", targetBlock, rpcURL)
266+
case <-time.After(500 * time.Millisecond):
267+
num, err := clt.BlockNumber(context.Background())
268+
if err != nil {
269+
continue
270+
}
271+
if num >= targetBlock {
272+
return
273+
}
274+
}
275+
}
276+
}

0 commit comments

Comments
 (0)