- Remove x-grpc-upstream metadata from gateway forwarding; use SetUpstream - Remove YAO_GRPC_TAI / YAO_GRPC_UPSTREAM env vars from sandbox containers - Delete benchmark-sandbox-v2 CI job (run benchmarks locally) - Simplify sandbox-v2 CI to tai SDK + workspace tests only - Add HostExec support to sandbox v2 box interface - Add K8s semaphore and cleanup mutex for test stability - Update design docs to reflect new architecture Made-with: Cursor
619 lines
17 KiB
Markdown
619 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"])
|
|
}
|
|
|
|
func TestBuildGRPCEnv_Remote(t *testing.T) {
|
|
env := sandbox.BuildGRPCEnv(&sandbox.Pool{Addr: "tai://gpu.internal"}, "sb-002", "tok", "ref")
|
|
assert.NotEmpty(t, env["YAO_GRPC_ADDR"])
|
|
}
|
|
|
|
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 `tai` (heartbeat), `openai-proxy`, Nginx, WS echo + SSE test services
|
|
- CI builds test image from source (Step 4.5) — ensures binary compatibility with latest tai SDK 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/
|
|
```
|