Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
14 changes: 10 additions & 4 deletions server/Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
SHELL := /bin/bash
.PHONY: oapi-generate build dev test clean
.PHONY: oapi-generate build dev test test-unit test-e2e clean

BIN_DIR ?= $(CURDIR)/bin
RECORDING_DIR ?= $(CURDIR)/recordings
Expand Down Expand Up @@ -30,12 +30,18 @@ build: | $(BIN_DIR)
dev: build $(RECORDING_DIR)
OUTPUT_DIR=$(RECORDING_DIR) DISPLAY_NUM=$(DISPLAY_NUM) ./bin/api

# we run the e2e tests separately so that we can see the logs from the e2e tests as they run instead of waiting for all tests to complete
test:
# `test` runs unit + e2e. The two are split so callers (e.g. the Hypeman CI job)
# can run just the e2e suite, and so e2e logs stream as they run instead of
# waiting for all unit tests to complete.
test: test-unit test-e2e

test-unit:
go vet ./...
go test -v -race $$(go list ./... | grep -v /e2e$$)

test-e2e:
@echo ""
@echo "=== Running e2e tests (testcontainers — this may take a few minutes) ==="
@echo "=== Running e2e tests (this may take a few minutes) ==="
@echo ""
go test -v -race -timeout 120m ./e2e/

Expand Down
119 changes: 119 additions & 0 deletions server/e2e/backend.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package e2e

import (
"context"
"os"
"strings"
"testing"

instanceoapi "github.com/kernel/kernel-images/server/lib/oapi"
)

// ContainerConfig holds optional configuration for instance startup.
//
// It is shared by every backend so that the ~24 e2e_*_test.go files can keep
// calling Start with the same shape regardless of where the browser instance
// actually runs (a local Docker container or a remote Hypeman VM).
type ContainerConfig struct {
Env map[string]string
// HostAccess requests that the browser instance be able to reach a service
// the test stands up on its own host (loopback) — used by tests with a local
// fixture server (capmonster, persisted-login). How it's provided is a
// backend detail (the Docker backend maps host.docker.internal); backends
// that cannot bridge a remote instance to the test host reject it.
HostAccess bool
}

// Backend is the abstraction every e2e browser-instance provider implements.
//
// It captures the public surface that the test files consume via *TestContainer.
// Two implementations exist:
//
// - dockerBackend: runs the image as a local Docker container via
// testcontainers-go (the historical behavior, still the default).
// - hypemanBackend: starts the image as a remote VM on a running Hypeman dev
// server using the github.com/kernel/hypeman-go client library.
//
// Keeping the surface identical means selecting a backend is a pure factory
// concern and requires no changes in individual tests.
type Backend interface {
// Start provisions and boots the browser instance.
Start(ctx context.Context, cfg ContainerConfig) error
// Stop tears the instance down and releases its resources.
Stop(ctx context.Context) error

// APIBaseURL returns the base URL for the instance's control-plane API
// server (container port 10001).
APIBaseURL() string
// CDPURL returns the WebSocket URL for the DevTools proxy (port 9222).
CDPURL() string
// CDPAddr returns the TCP host:port for the DevTools proxy (port 9222).
CDPAddr() string
// ChromeDriverURL returns the base HTTP URL for the ChromeDriver proxy
// (port 9224).
ChromeDriverURL() string

// APIClient returns an OpenAPI client bound to APIBaseURL.
APIClient() (*instanceoapi.ClientWithResponses, error)
// APIClientNoKeepAlive returns an OpenAPI client that disables HTTP
// connection reuse (useful after server restarts).
APIClientNoKeepAlive() (*instanceoapi.ClientWithResponses, error)

// WaitReady blocks until the instance's API server is serving.
WaitReady(ctx context.Context) error
// WaitDevTools blocks until the CDP endpoint accepts connections.
WaitDevTools(ctx context.Context) error
// WaitChromeDriver blocks until the ChromeDriver proxy reports ready.
WaitChromeDriver(ctx context.Context) error

// Exec runs a command inside the instance and returns the exit code and
// combined stdout+stderr output.
Exec(ctx context.Context, cmd []string) (int, string, error)

// ExitCh returns a channel that fires when the instance exits.
ExitCh() <-chan error
}

// BackendKind enumerates the supported e2e backends.
type BackendKind string

const (
BackendDocker BackendKind = "docker"
BackendHypeman BackendKind = "hypeman"
)

// envBackendKind is the env var that selects the backend. It defaults to
// "docker" so existing CI (which sets nothing) is unchanged.
const envBackendKind = "KI_E2E_BACKEND"

// backendKindFromEnv reads and normalizes KI_E2E_BACKEND, defaulting to docker.
func backendKindFromEnv() BackendKind {
v := strings.TrimSpace(strings.ToLower(os.Getenv(envBackendKind)))
if v == "" {
return BackendDocker
}
return BackendKind(v)
}

// newBackend constructs the backend selected by the KI_E2E_BACKEND env var.
//
// Selection is resolved here (and not per test) so that adding a backend never
// requires touching the test files. Unknown values fail the test loudly rather
// than silently falling back, to avoid masking misconfiguration in CI.
func newBackend(tb testing.TB, image string) Backend {
tb.Helper()
kind := backendKindFromEnv()
switch kind {
case BackendDocker:
return newDockerBackend(image)
case BackendHypeman:
b, err := newHypemanBackend(image, hypemanConfigFromEnv())
if err != nil {
tb.Fatalf("e2e: failed to configure hypeman backend: %v", err)
}
return b
default:
tb.Fatalf("e2e: unsupported %s=%q (want %q or %q)", envBackendKind, kind, BackendDocker, BackendHypeman)
return nil
}
}
239 changes: 239 additions & 0 deletions server/e2e/backend_docker.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
package e2e

import (
"context"
"fmt"
"net/http"
"time"

"github.com/docker/docker/api/types/container"
"github.com/docker/go-connections/nat"
instanceoapi "github.com/kernel/kernel-images/server/lib/oapi"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)

// dockerBackend runs the image as a local Docker container via testcontainers-go.
//
// This is the historical e2e behavior, preserved verbatim and moved behind the
// Backend interface. It enables parallel test execution by giving each test its
// own dynamically allocated host ports.
type dockerBackend struct {
Name string
Image string
APIPort int // dynamically allocated host port -> container 10001
CDPPort int // dynamically allocated host port -> container 9222
ChromeDriverPort int // dynamically allocated host port -> container 9224
ctr testcontainers.Container
}

// newDockerBackend returns a Docker-backed Backend for the given image.
func newDockerBackend(image string) Backend {
return &dockerBackend{Image: image}
}

// Start starts the container with the given configuration using testcontainers-go.
func (c *dockerBackend) Start(ctx context.Context, cfg ContainerConfig) error {
// Build environment variables
env := make(map[string]string)
for k, v := range cfg.Env {
env[k] = v
}
// Ensure CHROMIUM_FLAGS includes --no-sandbox for CI
if flags, ok := env["CHROMIUM_FLAGS"]; !ok {
env["CHROMIUM_FLAGS"] = "--no-sandbox"
} else if flags != "" {
env["CHROMIUM_FLAGS"] = flags + " --no-sandbox"
} else {
env["CHROMIUM_FLAGS"] = "--no-sandbox"
}

// Build container request options
opts := []testcontainers.ContainerCustomizer{
testcontainers.WithImage(c.Image),
testcontainers.WithExposedPorts("10001/tcp", "9222/tcp", "9224/tcp"),
testcontainers.WithEnv(env),
testcontainers.WithTmpfs(map[string]string{"/dev/shm": "size=2g,mode=1777"}),
// Set privileged mode for Chrome
testcontainers.WithHostConfigModifier(func(hc *container.HostConfig) {
hc.Privileged = true
}),
// Wait for the API to be ready
testcontainers.WithWaitStrategy(
wait.ForHTTP("/spec.yaml").
WithPort("10001/tcp").
WithStartupTimeout(2 * time.Minute),
),
}

// Add host access if requested
if cfg.HostAccess {
opts = append(opts, testcontainers.WithHostConfigModifier(func(hc *container.HostConfig) {
hc.ExtraHosts = append(hc.ExtraHosts, "host.docker.internal:host-gateway")
}))
}

// Start container
ctr, err := testcontainers.Run(ctx, c.Image, opts...)
if err != nil {
return fmt.Errorf("failed to start container: %w", err)
}
c.ctr = ctr

// Get container name
inspect, err := ctr.Inspect(ctx)
if err == nil {
c.Name = inspect.Name
}

// Get mapped ports
apiPort, err := ctr.MappedPort(ctx, "10001/tcp")
if err != nil {
return fmt.Errorf("failed to get API port: %w", err)
}
c.APIPort = apiPort.Int()

cdpPort, err := ctr.MappedPort(ctx, "9222/tcp")
if err != nil {
return fmt.Errorf("failed to get CDP port: %w", err)
}
c.CDPPort = cdpPort.Int()

chromeDriverPort, err := ctr.MappedPort(ctx, "9224/tcp")
if err != nil {
return fmt.Errorf("failed to get ChromeDriver port: %w", err)
}
c.ChromeDriverPort = chromeDriverPort.Int()

return nil
}

// Stop stops and removes the container.
func (c *dockerBackend) Stop(ctx context.Context) error {
if c.ctr == nil {
return nil
}
return testcontainers.TerminateContainer(c.ctr)
}

// APIBaseURL returns the URL for the container's API server.
func (c *dockerBackend) APIBaseURL() string {
return fmt.Sprintf("http://127.0.0.1:%d", c.APIPort)
}

// CDPURL returns the WebSocket URL for the container's DevTools proxy.
func (c *dockerBackend) CDPURL() string {
return fmt.Sprintf("ws://127.0.0.1:%d/", c.CDPPort)
}

// CDPAddr returns the TCP address for the container's DevTools proxy.
func (c *dockerBackend) CDPAddr() string {
return fmt.Sprintf("127.0.0.1:%d", c.CDPPort)
}

// ChromeDriverURL returns the base HTTP URL for the container's ChromeDriver proxy.
func (c *dockerBackend) ChromeDriverURL() string {
return fmt.Sprintf("http://127.0.0.1:%d", c.ChromeDriverPort)
}

// APIClient creates an OpenAPI client for this container's API.
func (c *dockerBackend) APIClient() (*instanceoapi.ClientWithResponses, error) {
return instanceoapi.NewClientWithResponses(c.APIBaseURL())
}

// APIClientNoKeepAlive creates an API client that doesn't reuse connections.
func (c *dockerBackend) APIClientNoKeepAlive() (*instanceoapi.ClientWithResponses, error) {
transport := &http.Transport{
DisableKeepAlives: true,
}
httpClient := &http.Client{Transport: transport}
return instanceoapi.NewClientWithResponses(c.APIBaseURL(), instanceoapi.WithHTTPClient(httpClient))
}

// WaitReady waits for the container's API to become ready.
// Note: With testcontainers-go, this is usually handled by the wait strategy in
// Start(). This method performs an additional health check.
func (c *dockerBackend) WaitReady(ctx context.Context) error {
url := c.APIBaseURL() + "/spec.yaml"
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()

client := &http.Client{Timeout: 2 * time.Second}

for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
resp, err := client.Get(url)
if err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return nil
}
}
}
}
}

// WaitDevTools waits for the CDP WebSocket endpoint to be ready.
func (c *dockerBackend) WaitDevTools(ctx context.Context) error {
return wait.ForListeningPort(nat.Port("9222/tcp")).
WithStartupTimeout(2*time.Minute).
WaitUntilReady(ctx, c.ctr)
}

// WaitChromeDriver waits for the ChromeDriver proxy (and upstream ChromeDriver)
// to be ready by polling the /status endpoint.
func (c *dockerBackend) WaitChromeDriver(ctx context.Context) error {
statusURL := c.ChromeDriverURL() + "/status"
client := &http.Client{Timeout: 2 * time.Second}
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()

for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
resp, err := client.Get(statusURL)
if err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return nil
}
}
}
}
}

// Exec executes a command inside the container and returns the combined output.
func (c *dockerBackend) Exec(ctx context.Context, cmd []string) (int, string, error) {
exitCode, reader, err := c.ctr.Exec(ctx, cmd)
if err != nil {
return exitCode, "", err
}

// Read all output
buf := make([]byte, 0)
tmp := make([]byte, 1024)
for {
n, err := reader.Read(tmp)
if n > 0 {
buf = append(buf, tmp[:n]...)
}
if err != nil {
break
}
}

return exitCode, string(buf), nil
}

// ExitCh returns a channel that receives when the container exits.
// Note: testcontainers-go handles this internally; this is kept for API
// compatibility and returns a channel that never fires.
func (c *dockerBackend) ExitCh() <-chan error {
ch := make(chan error, 1)
return ch
}
Loading
Loading