Skip to content

Commit ed8d06e

Browse files
feat: enable parallel e2e test execution with dynamic port allocation (#137)
Introduce TestContainer to manage Docker containers with dynamically allocated ports, eliminating port conflicts and enabling parallel test execution. This significantly speeds up CI runs on high-resource machines. Changes: - Add container.go with TestContainer struct for dynamic port management - Migrate all e2e tests to use TestContainer instead of global setup - Add t.Parallel() to test functions for concurrent execution - Remove -p 1 from Makefile now that port conflicts are resolved - Replace slog logging with t.Log/t.Logf for cleaner test output # Checklist - [ ] A link to a related issue in our repository - [ ] A description of the changes proposed in the pull request. - [ ] @mentions of the person or team responsible for reviewing proposed changes. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Primarily test harness and dependency changes, but it alters how Docker containers are launched/managed and enables parallel execution, which can introduce new CI flakiness and resource/cleanup issues. > > **Overview** > Refactors the e2e suite to run **in parallel** by introducing a `TestContainer` abstraction (based on `testcontainers-go`) that starts per-test Docker containers with **dynamically mapped API/CDP ports**, plus helpers for readiness checks and no-keepalive API clients. > > Updates the existing e2e tests/benchmarks to use this container wrapper (and `t.Parallel()` where applicable), replacing ad-hoc `docker run`/fixed-port assumptions and switching most logging from `slog` to `t.Log`/`b.Log`. The `Makefile` drops `go test -p 1` and Go module deps are updated to include Docker/testcontainers libraries to support this. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit f7417c8. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 640ca69 commit ed8d06e

11 files changed

+783
-466
lines changed

server/Makefile

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,8 @@ dev: build $(RECORDING_DIR)
3535

3636
test:
3737
go vet ./...
38-
# Run tests sequentially (-p 1) to avoid port conflicts in e2e tests
39-
# (all e2e tests bind to the same ports: 10001, 9222)
40-
go test -v -race -p 1 ./...
38+
# E2E tests use dynamic ports via TestContainer, enabling parallel execution
39+
go test -v -race ./...
4140

4241
clean:
4342
@rm -rf $(BIN_DIR)

server/e2e/container.go

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
package e2e
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
"testing"
8+
"time"
9+
10+
"github.com/docker/docker/api/types/container"
11+
"github.com/docker/go-connections/nat"
12+
instanceoapi "github.com/onkernel/kernel-images/server/lib/oapi"
13+
"github.com/testcontainers/testcontainers-go"
14+
"github.com/testcontainers/testcontainers-go/wait"
15+
)
16+
17+
// TestContainer wraps testcontainers-go to manage a Docker container for e2e tests.
18+
// This enables parallel test execution by giving each test its own dynamically allocated ports.
19+
type TestContainer struct {
20+
Name string
21+
Image string
22+
APIPort int // dynamically allocated host port -> container 10001
23+
CDPPort int // dynamically allocated host port -> container 9222
24+
ctr testcontainers.Container
25+
}
26+
27+
// ContainerConfig holds optional configuration for container startup.
28+
type ContainerConfig struct {
29+
Env map[string]string
30+
HostAccess bool // Add host.docker.internal mapping
31+
}
32+
33+
// NewTestContainer creates a new test container placeholder.
34+
// The actual container is started when Start() is called.
35+
// Works with both *testing.T and *testing.B (any testing.TB).
36+
func NewTestContainer(tb testing.TB, image string) *TestContainer {
37+
tb.Helper()
38+
return &TestContainer{
39+
Image: image,
40+
}
41+
}
42+
43+
// Start starts the container with the given configuration using testcontainers-go.
44+
func (c *TestContainer) Start(ctx context.Context, cfg ContainerConfig) error {
45+
// Build environment variables
46+
env := make(map[string]string)
47+
for k, v := range cfg.Env {
48+
env[k] = v
49+
}
50+
// Ensure CHROMIUM_FLAGS includes --no-sandbox for CI
51+
if flags, ok := env["CHROMIUM_FLAGS"]; !ok {
52+
env["CHROMIUM_FLAGS"] = "--no-sandbox"
53+
} else if flags != "" {
54+
env["CHROMIUM_FLAGS"] = flags + " --no-sandbox"
55+
} else {
56+
env["CHROMIUM_FLAGS"] = "--no-sandbox"
57+
}
58+
59+
// Build container request options
60+
opts := []testcontainers.ContainerCustomizer{
61+
testcontainers.WithImage(c.Image),
62+
testcontainers.WithExposedPorts("10001/tcp", "9222/tcp"),
63+
testcontainers.WithEnv(env),
64+
testcontainers.WithTmpfs(map[string]string{"/dev/shm": "size=2g,mode=1777"}),
65+
// Set privileged mode for Chrome
66+
testcontainers.WithHostConfigModifier(func(hc *container.HostConfig) {
67+
hc.Privileged = true
68+
}),
69+
// Wait for the API to be ready
70+
testcontainers.WithWaitStrategy(
71+
wait.ForHTTP("/spec.yaml").
72+
WithPort("10001/tcp").
73+
WithStartupTimeout(2 * time.Minute),
74+
),
75+
}
76+
77+
// Add host access if requested
78+
if cfg.HostAccess {
79+
opts = append(opts, testcontainers.WithHostConfigModifier(func(hc *container.HostConfig) {
80+
hc.ExtraHosts = append(hc.ExtraHosts, "host.docker.internal:host-gateway")
81+
}))
82+
}
83+
84+
// Start container
85+
ctr, err := testcontainers.Run(ctx, c.Image, opts...)
86+
if err != nil {
87+
return fmt.Errorf("failed to start container: %w", err)
88+
}
89+
c.ctr = ctr
90+
91+
// Get container name
92+
inspect, err := ctr.Inspect(ctx)
93+
if err == nil {
94+
c.Name = inspect.Name
95+
}
96+
97+
// Get mapped ports
98+
apiPort, err := ctr.MappedPort(ctx, "10001/tcp")
99+
if err != nil {
100+
return fmt.Errorf("failed to get API port: %w", err)
101+
}
102+
c.APIPort = apiPort.Int()
103+
104+
cdpPort, err := ctr.MappedPort(ctx, "9222/tcp")
105+
if err != nil {
106+
return fmt.Errorf("failed to get CDP port: %w", err)
107+
}
108+
c.CDPPort = cdpPort.Int()
109+
110+
return nil
111+
}
112+
113+
// Stop stops and removes the container.
114+
func (c *TestContainer) Stop(ctx context.Context) error {
115+
if c.ctr == nil {
116+
return nil
117+
}
118+
return testcontainers.TerminateContainer(c.ctr)
119+
}
120+
121+
// APIBaseURL returns the URL for the container's API server.
122+
func (c *TestContainer) APIBaseURL() string {
123+
return fmt.Sprintf("http://127.0.0.1:%d", c.APIPort)
124+
}
125+
126+
// CDPURL returns the WebSocket URL for the container's DevTools proxy.
127+
func (c *TestContainer) CDPURL() string {
128+
return fmt.Sprintf("ws://127.0.0.1:%d/", c.CDPPort)
129+
}
130+
131+
// APIClient creates an OpenAPI client for this container's API.
132+
func (c *TestContainer) APIClient() (*instanceoapi.ClientWithResponses, error) {
133+
return instanceoapi.NewClientWithResponses(c.APIBaseURL())
134+
}
135+
136+
// WaitReady waits for the container's API to become ready.
137+
// Note: With testcontainers-go, this is usually handled by the wait strategy in Start().
138+
// This method is kept for compatibility and performs an additional health check.
139+
func (c *TestContainer) WaitReady(ctx context.Context) error {
140+
url := c.APIBaseURL() + "/spec.yaml"
141+
ticker := time.NewTicker(200 * time.Millisecond)
142+
defer ticker.Stop()
143+
144+
client := &http.Client{Timeout: 2 * time.Second}
145+
146+
for {
147+
select {
148+
case <-ctx.Done():
149+
return ctx.Err()
150+
case <-ticker.C:
151+
resp, err := client.Get(url)
152+
if err == nil {
153+
resp.Body.Close()
154+
if resp.StatusCode == http.StatusOK {
155+
return nil
156+
}
157+
}
158+
}
159+
}
160+
}
161+
162+
// ExitCh returns a channel that receives when the container exits.
163+
// Note: testcontainers-go handles this internally; this is kept for API compatibility.
164+
func (c *TestContainer) ExitCh() <-chan error {
165+
ch := make(chan error, 1)
166+
// testcontainers-go doesn't expose an exit channel directly
167+
// Return a channel that never fires - container lifecycle is managed by testcontainers
168+
return ch
169+
}
170+
171+
// WaitDevTools waits for the CDP WebSocket endpoint to be ready.
172+
func (c *TestContainer) WaitDevTools(ctx context.Context) error {
173+
return wait.ForListeningPort(nat.Port("9222/tcp")).
174+
WithStartupTimeout(2 * time.Minute).
175+
WaitUntilReady(ctx, c.ctr)
176+
}
177+
178+
// APIClientNoKeepAlive creates an API client that doesn't reuse connections.
179+
// This is useful after server restarts where existing connections may be stale.
180+
func (c *TestContainer) APIClientNoKeepAlive() (*instanceoapi.ClientWithResponses, error) {
181+
transport := &http.Transport{
182+
DisableKeepAlives: true,
183+
}
184+
httpClient := &http.Client{Transport: transport}
185+
return instanceoapi.NewClientWithResponses(c.APIBaseURL(), instanceoapi.WithHTTPClient(httpClient))
186+
}
187+
188+
// CDPAddr returns the TCP address for the container's DevTools proxy.
189+
func (c *TestContainer) CDPAddr() string {
190+
return fmt.Sprintf("127.0.0.1:%d", c.CDPPort)
191+
}
192+
193+
// Exec executes a command inside the container and returns the combined output.
194+
func (c *TestContainer) Exec(ctx context.Context, cmd []string) (int, string, error) {
195+
exitCode, reader, err := c.ctr.Exec(ctx, cmd)
196+
if err != nil {
197+
return exitCode, "", err
198+
}
199+
200+
// Read all output
201+
buf := make([]byte, 0)
202+
tmp := make([]byte, 1024)
203+
for {
204+
n, err := reader.Read(tmp)
205+
if n > 0 {
206+
buf = append(buf, tmp[:n]...)
207+
}
208+
if err != nil {
209+
break
210+
}
211+
}
212+
213+
return exitCode, string(buf), nil
214+
}
215+
216+
// Container returns the underlying testcontainers.Container for advanced usage.
217+
func (c *TestContainer) Container() testcontainers.Container {
218+
return c.ctr
219+
}

0 commit comments

Comments
 (0)