- Add new gRPC endpoint for Heartbeat in the Yao service, enabling communication with the sandbox. - Update Makefile to include a dedicated unit test target for Sandbox V2, ensuring proper testing of new features. - Enhance CI workflows to incorporate Sandbox V2 tests, allowing for dual-mode testing (local and remote) with Docker. - Modify .gitignore to exclude specific Docker files while allowing shell scripts for Sandbox V2. - Update documentation in DESIGN.md to reflect the new architecture and capabilities of the Sandbox V2. These changes enhance the Yao SDK's functionality, providing improved support for sandbox operations and testing.
621 lines
17 KiB
Markdown
621 lines
17 KiB
Markdown
# Sandbox V2 — Test Specification
|
|
|
|
Design: [DESIGN.md](./DESIGN.md) | Implementation: [IMPL.md](./IMPL.md)
|
|
|
|
## Principles
|
|
|
|
- **Black-box testing**: all `*_test.go` files use `package sandbox_test` — tests only access exported API
|
|
- **Real containers**: tests create real Docker containers via tai SDK, no mocking
|
|
- **Skip when unavailable**: `skipIfNoDocker(t)` / `skipIfNoTai(t)` — CI has Docker and Tai; local dev may not
|
|
- **Tests follow implementation**: `*_test.go` lives next to the code it tests
|
|
- **Coverage > 80%**: per file and overall
|
|
|
|
## Prerequisites
|
|
|
|
```bash
|
|
source $YAO_SOURCE_ROOT/env.local.sh
|
|
```
|
|
|
|
### Docker (required for all container tests)
|
|
|
|
Docker daemon must be running. Tests connect via default socket.
|
|
|
|
### Tai (required for remote-mode tests only)
|
|
|
|
```bash
|
|
docker run -d --name tai \
|
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
|
-p 2375:2375 -p 9100:9100 -p 8080:8080 -p 6080:6080 \
|
|
yaoapp/tai:latest
|
|
```
|
|
|
|
### Environment Variables
|
|
|
|
| Variable | Purpose | Default |
|
|
|----------|---------|---------|
|
|
| `YAO_TEST_APPLICATION` | Path to `yao-dev-app` | — (required) |
|
|
| `YAO_DB_DRIVER` / `YAO_DB_PRIMARY` | Database connection | — (required) |
|
|
| `YAO_JWT_SECRET` / `YAO_DB_AESKEY` | Crypto keys (for OAuth token creation) | — (required) |
|
|
| `SANDBOX_TEST_IMAGE` | Container image for tests | `yaoapp/sandbox-v2-test:latest` |
|
|
| `SANDBOX_TEST_REMOTE_ADDR` | Tai remote address, e.g. `tai://127.0.0.1` | — (skip remote tests if empty) |
|
|
| `TAI_TEST_HOST` | Tai HTTP proxy host | `127.0.0.1` |
|
|
|
|
## Directory Structure
|
|
|
|
```
|
|
sandbox/v2/
|
|
├── sandbox.go
|
|
├── sandbox_test.go # Init/M singleton tests
|
|
├── manager.go
|
|
├── manager_test.go # Create/Get/GetOrCreate/List/Remove, pool management
|
|
├── manager_lifecycle_test.go # Start recovery, Cleanup, idle tracking, heartbeat
|
|
├── box.go
|
|
├── box_test.go # Exec/Stream/Workspace/Proxy/VNC, lifecycle
|
|
├── box_attach_test.go # Attach WS/SSE (needs service in container)
|
|
├── config.go
|
|
├── types.go
|
|
├── errors.go
|
|
├── grpc.go
|
|
├── grpc_test.go # OAuth token creation/revocation, env var building
|
|
├── testutils_test.go # shared test helpers (unexported, package sandbox_test)
|
|
└── DESIGN.md
|
|
```
|
|
|
|
## testutils (internal to sandbox_test)
|
|
|
|
Shared helpers in `testutils_test.go` — not a separate package, lives inside `package sandbox_test`.
|
|
|
|
```go
|
|
// testutils_test.go
|
|
package sandbox_test
|
|
|
|
// skipIfNoDocker skips the test if Docker is not available.
|
|
func skipIfNoDocker(t *testing.T)
|
|
|
|
// skipIfNoTai skips the test if SANDBOX_TEST_REMOTE_ADDR is empty.
|
|
func skipIfNoTai(t *testing.T)
|
|
|
|
// testImage returns SANDBOX_TEST_IMAGE or "yaoapp/sandbox-v2-test:latest".
|
|
func testImage() string
|
|
|
|
// setupManager initializes sandbox with a local pool, returns cleanup func.
|
|
// Calls sandbox.Init + sandbox.M().Start.
|
|
func setupManager(t *testing.T) func()
|
|
|
|
// setupManagerWithRemote initializes sandbox with local + remote pools.
|
|
func setupManagerWithRemote(t *testing.T) func()
|
|
|
|
// createTestBox creates a box with defaults and returns it. Registers t.Cleanup for removal.
|
|
func createTestBox(t *testing.T, opts ...sandbox.CreateOption) *sandbox.Box
|
|
```
|
|
|
|
## How to Write a Test
|
|
|
|
### Standard pattern
|
|
|
|
```go
|
|
// manager_test.go
|
|
package sandbox_test
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
sandbox "github.com/yaoapp/yao/sandbox/v2"
|
|
)
|
|
|
|
func TestCreate(t *testing.T) {
|
|
skipIfNoDocker(t)
|
|
cleanup := setupManager(t)
|
|
defer cleanup()
|
|
|
|
box, err := sandbox.M().Create(context.Background(), sandbox.CreateOptions{
|
|
Image: testImage(),
|
|
Owner: "test-user",
|
|
})
|
|
require.NoError(t, err)
|
|
defer box.Remove(context.Background())
|
|
|
|
assert.NotEmpty(t, box.ID())
|
|
assert.Equal(t, "test-user", box.Owner())
|
|
}
|
|
|
|
func TestCreate_NoImage(t *testing.T) {
|
|
skipIfNoDocker(t)
|
|
cleanup := setupManager(t)
|
|
defer cleanup()
|
|
|
|
_, err := sandbox.M().Create(context.Background(), sandbox.CreateOptions{})
|
|
assert.Error(t, err) // Image is required
|
|
}
|
|
```
|
|
|
|
### Container execution tests
|
|
|
|
```go
|
|
// box_test.go
|
|
package sandbox_test
|
|
|
|
func TestExec(t *testing.T) {
|
|
skipIfNoDocker(t)
|
|
cleanup := setupManager(t)
|
|
defer cleanup()
|
|
box := createTestBox(t)
|
|
|
|
result, err := box.Exec(context.Background(), []string{"echo", "hello"})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 0, result.ExitCode)
|
|
assert.Equal(t, "hello\n", result.Stdout)
|
|
assert.Empty(t, result.Stderr)
|
|
}
|
|
|
|
func TestExec_NonZeroExit(t *testing.T) {
|
|
skipIfNoDocker(t)
|
|
cleanup := setupManager(t)
|
|
defer cleanup()
|
|
box := createTestBox(t)
|
|
|
|
result, err := box.Exec(context.Background(), []string{"sh", "-c", "exit 42"})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 42, result.ExitCode)
|
|
}
|
|
```
|
|
|
|
### Streaming tests
|
|
|
|
```go
|
|
// box_test.go
|
|
package sandbox_test
|
|
|
|
func TestStream(t *testing.T) {
|
|
skipIfNoDocker(t)
|
|
cleanup := setupManager(t)
|
|
defer cleanup()
|
|
box := createTestBox(t)
|
|
|
|
s, err := box.Stream(context.Background(), []string{"sh", "-c", "echo a; sleep 0.1; echo b"})
|
|
require.NoError(t, err)
|
|
|
|
out, _ := io.ReadAll(s.Stdout)
|
|
code, err := s.Wait()
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, 0, code)
|
|
assert.Contains(t, string(out), "a\n")
|
|
assert.Contains(t, string(out), "b\n")
|
|
}
|
|
|
|
func TestStream_Cancel(t *testing.T) {
|
|
skipIfNoDocker(t)
|
|
cleanup := setupManager(t)
|
|
defer cleanup()
|
|
box := createTestBox(t)
|
|
|
|
s, err := box.Stream(context.Background(), []string{"sleep", "60"})
|
|
require.NoError(t, err)
|
|
|
|
s.Cancel()
|
|
code, _ := s.Wait()
|
|
assert.NotEqual(t, 0, code) // killed
|
|
}
|
|
|
|
func TestStream_Stdin(t *testing.T) {
|
|
skipIfNoDocker(t)
|
|
cleanup := setupManager(t)
|
|
defer cleanup()
|
|
box := createTestBox(t)
|
|
|
|
s, err := box.Stream(context.Background(), []string{"cat"})
|
|
require.NoError(t, err)
|
|
|
|
s.Stdin.Write([]byte("hello\n"))
|
|
s.Stdin.Close()
|
|
|
|
out, _ := io.ReadAll(s.Stdout)
|
|
code, _ := s.Wait()
|
|
assert.Equal(t, 0, code)
|
|
assert.Equal(t, "hello\n", string(out))
|
|
}
|
|
```
|
|
|
|
### Workspace tests
|
|
|
|
```go
|
|
// box_test.go
|
|
package sandbox_test
|
|
|
|
func TestWorkspace_ReadWrite(t *testing.T) {
|
|
skipIfNoDocker(t)
|
|
cleanup := setupManager(t)
|
|
defer cleanup()
|
|
box := createTestBox(t)
|
|
|
|
ws := box.Workspace()
|
|
err := ws.WriteFile("test.txt", []byte("hello"), 0644)
|
|
require.NoError(t, err)
|
|
|
|
data, err := fs.ReadFile(ws, "test.txt")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "hello", string(data))
|
|
}
|
|
|
|
func TestWorkspace_MkdirAll(t *testing.T) {
|
|
skipIfNoDocker(t)
|
|
cleanup := setupManager(t)
|
|
defer cleanup()
|
|
box := createTestBox(t)
|
|
|
|
ws := box.Workspace()
|
|
err := ws.MkdirAll("a/b/c", 0755)
|
|
require.NoError(t, err)
|
|
|
|
info, err := fs.Stat(ws, "a/b/c")
|
|
require.NoError(t, err)
|
|
assert.True(t, info.IsDir())
|
|
}
|
|
|
|
func TestWorkspace_WalkDir(t *testing.T) {
|
|
skipIfNoDocker(t)
|
|
cleanup := setupManager(t)
|
|
defer cleanup()
|
|
box := createTestBox(t)
|
|
|
|
ws := box.Workspace()
|
|
ws.MkdirAll("src", 0755)
|
|
ws.WriteFile("src/main.go", []byte("package main"), 0644)
|
|
ws.WriteFile("src/util.go", []byte("package main"), 0644)
|
|
|
|
var files []string
|
|
fs.WalkDir(ws, "src", func(path string, d fs.DirEntry, err error) error {
|
|
if !d.IsDir() { files = append(files, path) }
|
|
return nil
|
|
})
|
|
assert.Len(t, files, 2)
|
|
}
|
|
```
|
|
|
|
### Lifecycle tests
|
|
|
|
```go
|
|
// manager_lifecycle_test.go
|
|
package sandbox_test
|
|
|
|
func TestIdleCleanup_Session(t *testing.T) {
|
|
skipIfNoDocker(t)
|
|
cleanup := setupManager(t)
|
|
defer cleanup()
|
|
|
|
box, err := sandbox.M().Create(context.Background(), sandbox.CreateOptions{
|
|
Image: testImage(),
|
|
Policy: sandbox.Session,
|
|
IdleTimeout: 2 * time.Second,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Box exists
|
|
_, err = sandbox.M().Get(context.Background(), box.ID())
|
|
assert.NoError(t, err)
|
|
|
|
// Wait for idle + cleanup cycle
|
|
time.Sleep(4 * time.Second)
|
|
sandbox.M().Cleanup(context.Background())
|
|
|
|
// Box should be gone
|
|
_, err = sandbox.M().Get(context.Background(), box.ID())
|
|
assert.ErrorIs(t, err, sandbox.ErrNotFound)
|
|
}
|
|
|
|
func TestStartRecovery(t *testing.T) {
|
|
skipIfNoDocker(t)
|
|
|
|
// Phase 1: create a box, then shut down Manager
|
|
cleanup1 := setupManager(t)
|
|
box, err := sandbox.M().Create(context.Background(), sandbox.CreateOptions{
|
|
Image: testImage(),
|
|
Owner: "recovery-test",
|
|
})
|
|
require.NoError(t, err)
|
|
boxID := box.ID()
|
|
cleanup1() // closes Manager, but container stays
|
|
|
|
// Phase 2: new Manager, Start should discover the container
|
|
cleanup2 := setupManager(t)
|
|
defer cleanup2()
|
|
|
|
recovered, err := sandbox.M().Get(context.Background(), boxID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, boxID, recovered.ID())
|
|
assert.Equal(t, "recovery-test", recovered.Owner())
|
|
|
|
// Clean up
|
|
recovered.Remove(context.Background())
|
|
}
|
|
|
|
func TestHeartbeat(t *testing.T) {
|
|
skipIfNoDocker(t)
|
|
cleanup := setupManager(t)
|
|
defer cleanup()
|
|
box := createTestBox(t)
|
|
|
|
// Simulate heartbeat
|
|
err := sandbox.M().Heartbeat(box.ID(), true, 3)
|
|
assert.NoError(t, err)
|
|
|
|
info, _ := box.Info(context.Background())
|
|
assert.Equal(t, 3, info.ProcessCount)
|
|
}
|
|
|
|
func TestHeartbeat_NotFound(t *testing.T) {
|
|
skipIfNoDocker(t)
|
|
cleanup := setupManager(t)
|
|
defer cleanup()
|
|
|
|
err := sandbox.M().Heartbeat("nonexistent", true, 1)
|
|
assert.ErrorIs(t, err, sandbox.ErrNotFound)
|
|
}
|
|
```
|
|
|
|
### Pool management tests
|
|
|
|
```go
|
|
// manager_test.go
|
|
package sandbox_test
|
|
|
|
func TestPoolLimits_MaxTotal(t *testing.T) {
|
|
skipIfNoDocker(t)
|
|
|
|
// Init with MaxTotal=1
|
|
err := sandbox.Init(sandbox.Config{
|
|
Pool: []sandbox.Pool{{
|
|
Name: "limited",
|
|
Addr: "local",
|
|
MaxTotal: 1,
|
|
}},
|
|
})
|
|
require.NoError(t, err)
|
|
sandbox.M().Start(context.Background())
|
|
defer sandbox.M().Close()
|
|
|
|
box1, err := sandbox.M().Create(context.Background(), sandbox.CreateOptions{
|
|
Image: testImage(),
|
|
})
|
|
require.NoError(t, err)
|
|
defer box1.Remove(context.Background())
|
|
|
|
_, err = sandbox.M().Create(context.Background(), sandbox.CreateOptions{
|
|
Image: testImage(),
|
|
})
|
|
assert.ErrorIs(t, err, sandbox.ErrLimitExceeded)
|
|
}
|
|
|
|
func TestAddPool(t *testing.T) {
|
|
skipIfNoDocker(t)
|
|
cleanup := setupManager(t)
|
|
defer cleanup()
|
|
|
|
err := sandbox.M().AddPool(context.Background(), sandbox.Pool{
|
|
Name: "new-pool",
|
|
Addr: "local",
|
|
})
|
|
assert.NoError(t, err)
|
|
|
|
pools := sandbox.M().Pools()
|
|
names := make([]string, len(pools))
|
|
for i, p := range pools { names[i] = p.Name }
|
|
assert.Contains(t, names, "new-pool")
|
|
}
|
|
|
|
func TestRemovePool_InUse(t *testing.T) {
|
|
skipIfNoDocker(t)
|
|
cleanup := setupManager(t)
|
|
defer cleanup()
|
|
|
|
box := createTestBox(t)
|
|
_ = box
|
|
|
|
err := sandbox.M().RemovePool(context.Background(), "local", false)
|
|
assert.ErrorIs(t, err, sandbox.ErrPoolInUse)
|
|
}
|
|
```
|
|
|
|
### Multi-pool tests
|
|
|
|
```go
|
|
// manager_test.go
|
|
package sandbox_test
|
|
|
|
func TestMultiPool(t *testing.T) {
|
|
skipIfNoDocker(t)
|
|
skipIfNoTai(t)
|
|
cleanup := setupManagerWithRemote(t)
|
|
defer cleanup()
|
|
|
|
// Create on local
|
|
local, err := sandbox.M().Create(context.Background(), sandbox.CreateOptions{
|
|
Image: testImage(),
|
|
Pool: "local",
|
|
})
|
|
require.NoError(t, err)
|
|
defer local.Remove(context.Background())
|
|
|
|
// Create on remote
|
|
remote, err := sandbox.M().Create(context.Background(), sandbox.CreateOptions{
|
|
Image: testImage(),
|
|
Pool: "remote",
|
|
})
|
|
require.NoError(t, err)
|
|
defer remote.Remove(context.Background())
|
|
|
|
// Both should exec
|
|
r1, _ := local.Exec(context.Background(), []string{"echo", "local"})
|
|
r2, _ := remote.Exec(context.Background(), []string{"echo", "remote"})
|
|
assert.Equal(t, "local\n", r1.Stdout)
|
|
assert.Equal(t, "remote\n", r2.Stdout)
|
|
}
|
|
```
|
|
|
|
### OAuth / gRPC env injection tests
|
|
|
|
```go
|
|
// grpc_test.go
|
|
package sandbox_test
|
|
|
|
func TestBuildGRPCEnv_Local(t *testing.T) {
|
|
env := sandbox.BuildGRPCEnv(&sandbox.Pool{Addr: "local"}, "sb-001", "tok", "ref")
|
|
assert.Equal(t, "sb-001", env["YAO_SANDBOX_ID"])
|
|
assert.Equal(t, "tok", env["YAO_TOKEN"])
|
|
assert.Equal(t, "ref", env["YAO_REFRESH_TOKEN"])
|
|
assert.NotEmpty(t, env["YAO_GRPC_ADDR"])
|
|
assert.Empty(t, env["YAO_GRPC_TAI"])
|
|
}
|
|
|
|
func TestBuildGRPCEnv_Remote(t *testing.T) {
|
|
env := sandbox.BuildGRPCEnv(&sandbox.Pool{Addr: "tai://gpu.internal"}, "sb-002", "tok", "ref")
|
|
assert.Equal(t, "enable", env["YAO_GRPC_TAI"])
|
|
assert.NotEmpty(t, env["YAO_GRPC_UPSTREAM"])
|
|
}
|
|
|
|
func TestCreateContainerTokens(t *testing.T) {
|
|
// Requires Yao runtime for OAuth
|
|
cleanup := setupManager(t)
|
|
defer cleanup()
|
|
|
|
access, refresh, err := sandbox.CreateContainerTokens("sb-test", "user-1")
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, access)
|
|
assert.NotEmpty(t, refresh)
|
|
}
|
|
```
|
|
|
|
## Required Test Cases
|
|
|
|
| File | Required Cases |
|
|
|------|---------------|
|
|
| `sandbox_test.go` | `Init` succeeds / `M()` panics before Init / double Init is safe |
|
|
| `manager_test.go` | Create / Create with explicit ID / Create no image (error) / Get / Get not found / GetOrCreate / List / List with owner filter / Remove / pool limits MaxTotal / pool limits MaxPerUser / AddPool / RemovePool / RemovePool in use / Pools |
|
|
| `manager_lifecycle_test.go` | Start recovery from labels / Cleanup Session idle / Cleanup LongRunning stop then remove / Persistent never cleaned / Heartbeat updates / Heartbeat not found / OneShot removed after Exec |
|
|
| `box_test.go` | Exec success / Exec non-zero exit / Exec with WorkDir / Exec with Env / Exec with Timeout / Stream read / Stream cancel / Stream stdin / Workspace ReadFile+WriteFile / Workspace MkdirAll / Workspace Remove / Workspace Rename / Workspace WalkDir / VNC (skip if no VNC image) / Proxy URL / Start+Stop+Start / Info |
|
|
| `box_attach_test.go` | Attach WS (skip if no WS server image) / Attach SSE (skip if no SSE server image) |
|
|
| `grpc_test.go` | BuildGRPCEnv local / BuildGRPCEnv remote / CreateContainerTokens / RevokeContainerTokens |
|
|
|
|
## Makefile
|
|
|
|
Add to [Makefile](../../Makefile):
|
|
|
|
```makefile
|
|
TESTFOLDER_SANDBOX_V2 := $(shell $(GO) list ./sandbox/v2/...)
|
|
|
|
.PHONY: unit-test-sandbox-v2
|
|
unit-test-sandbox-v2:
|
|
echo "mode: count" > coverage.out
|
|
for d in $(TESTFOLDER_SANDBOX_V2); do \
|
|
$(GO) test -tags $(TESTTAGS) -v -timeout=10m \
|
|
-covermode=count -coverprofile=profile.out \
|
|
-coverpkg=$$d \
|
|
$$d > tmp.out; \
|
|
cat tmp.out; \
|
|
if grep -q "^--- FAIL" tmp.out; then \
|
|
rm tmp.out; \
|
|
exit 1; \
|
|
elif grep -q "build failed" tmp.out; then \
|
|
rm tmp.out; \
|
|
exit 1; \
|
|
fi; \
|
|
if [ -f profile.out ]; then \
|
|
cat profile.out | grep -v "mode:" >> coverage.out; \
|
|
rm profile.out; \
|
|
fi; \
|
|
done
|
|
```
|
|
|
|
## CI Integration
|
|
|
|
Add `sandbox-v2-test` job to `unit-test.yml` and `pr-test.yml`:
|
|
|
|
```yaml
|
|
sandbox-v2-test:
|
|
runs-on: ubuntu-latest
|
|
services:
|
|
mongodb:
|
|
image: mongo:6.0
|
|
ports:
|
|
- 27017:27017
|
|
env:
|
|
MONGO_INITDB_ROOT_USERNAME: root
|
|
MONGO_INITDB_ROOT_PASSWORD: "123456"
|
|
MONGO_INITDB_DATABASE: test
|
|
strategy:
|
|
matrix:
|
|
go: ["1.25"]
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: actions/setup-go@v5
|
|
with:
|
|
go-version: ${{ matrix.go }}
|
|
|
|
- name: Start Tai container
|
|
run: |
|
|
docker run -d --name tai \
|
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
|
-p 2375:2375 -p 9100:9100 -p 8080:8080 -p 6080:6080 \
|
|
yaoapp/tai:latest
|
|
sleep 3
|
|
|
|
- name: Build V2 test image
|
|
run: |
|
|
cd sandbox/docker
|
|
bash build.sh v2
|
|
|
|
- name: Setup ENV
|
|
run: |
|
|
echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV
|
|
echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV
|
|
echo "SANDBOX_TEST_IMAGE=yaoapp/sandbox-v2-test:latest" >> $GITHUB_ENV
|
|
echo "SANDBOX_TEST_REMOTE_ADDR=tai://127.0.0.1" >> $GITHUB_ENV
|
|
echo "TAI_TEST_HOST=127.0.0.1" >> $GITHUB_ENV
|
|
mkdir -p ${{ github.WORKSPACE }}/../app/db
|
|
|
|
- name: Run Sandbox V2 Tests
|
|
run: make unit-test-sandbox-v2
|
|
|
|
- name: Codecov Report
|
|
uses: codecov/codecov-action@v4
|
|
with:
|
|
token: ${{ secrets.CODECOV_TOKEN }}
|
|
```
|
|
|
|
Key decisions:
|
|
- SQLite only — sandbox is infrastructure, not data-model dependent
|
|
- Tai container provides remote mode — exercises the full proxy path
|
|
- `sandbox-v2-test` as default test image — includes `yao-grpc` (heartbeat), `claude-proxy`, Nginx, WS echo + SSE test services
|
|
- CI builds test image from source (Step 4.5) — ensures binary compatibility with latest tai SDK + yao-grpc changes
|
|
- Attach tests (WS/SSE) use `sandbox-v2-test` image's built-in test services
|
|
|
|
## Coverage
|
|
|
|
- Target: >80% per file, >80% overall
|
|
- `sandbox.go` (singleton) covered via `sandbox_test.go`
|
|
- `manager.go` is the heaviest file — must have dedicated `manager_test.go` + `manager_lifecycle_test.go`
|
|
- `box.go` exercises all tai SDK integration points
|
|
- `grpc.go` tested with pure unit tests (token generation, env building)
|
|
|
|
## Running Tests
|
|
|
|
```bash
|
|
# All sandbox v2 tests (local Docker only)
|
|
make unit-test-sandbox-v2
|
|
|
|
# With remote mode (start Tai first)
|
|
SANDBOX_TEST_REMOTE_ADDR=tai://127.0.0.1 make unit-test-sandbox-v2
|
|
|
|
# Single file
|
|
go test -v ./sandbox/v2/ -run TestCreate
|
|
|
|
# Single test
|
|
go test -v ./sandbox/v2/ -run TestExec_NonZeroExit
|
|
|
|
# With race detector
|
|
go test -race -v ./sandbox/v2/
|
|
```
|