- Add gRPC server configuration to the application, allowing for gRPC communication. - Introduce new Makefile targets for gRPC unit testing and proto code generation. - Update CI workflows to include gRPC tests with SQLite as the transport layer. - Refactor the sandbox design to support multi-node capabilities and improve isolation. - Enhance the service layer to facilitate internal request forwarding for gRPC APIs. This commit lays the groundwork for integrating gRPC into the Yao SDK, improving performance and scalability.
426 lines
12 KiB
Markdown
426 lines
12 KiB
Markdown
# Yao gRPC Server — Test Specification
|
|
|
|
Design: [DESIGN.md](./DESIGN.md) | Implementation: [IMPL.md](./IMPL.md)
|
|
|
|
## Principles
|
|
|
|
- **Black-box testing**: all `*_test.go` files use `package xxx_test` — tests only access exported API via gRPC client
|
|
- **Tests follow implementation**: `*_test.go` lives next to the code it tests (`grpc/auth/guard_test.go` beside `grpc/auth/guard.go`)
|
|
- **Real server**: every test starts a real gRPC server on a random TCP port, exercises the full interceptor → handler chain
|
|
- **Coverage > 80%**: per sub-package and overall
|
|
|
|
## Prerequisites
|
|
|
|
```bash
|
|
source $YAO_SOURCE_ROOT/env.local.sh
|
|
```
|
|
|
|
Required environment variables (same as existing Yao tests):
|
|
|
|
| Variable | Purpose |
|
|
|----------|---------|
|
|
| `YAO_TEST_APPLICATION` | Path to `yao-dev-app` |
|
|
| `YAO_DB_DRIVER` / `YAO_DB_PRIMARY` | Database connection |
|
|
| `YAO_JWT_SECRET` / `YAO_DB_AESKEY` | Crypto keys |
|
|
| `OPENAI_TEST_KEY` | LLM streaming tests |
|
|
| `ANTHROPIC_API_KEY` | LLM streaming tests (Anthropic) |
|
|
|
|
## Directory Structure
|
|
|
|
```
|
|
grpc/
|
|
├── grpc.go
|
|
├── tests/
|
|
│ └── testutils/
|
|
│ └── testutils.go # shared test utilities
|
|
├── auth/
|
|
│ ├── guard.go
|
|
│ ├── guard_test.go # package auth_test
|
|
│ ├── endpoint.go
|
|
│ ├── endpoint_test.go # package auth_test
|
|
│ └── scope.go
|
|
├── run/
|
|
│ ├── run.go
|
|
│ └── run_test.go # package run_test
|
|
├── shell/
|
|
│ ├── shell.go
|
|
│ └── shell_test.go # package shell_test
|
|
├── api/
|
|
│ ├── api.go
|
|
│ └── api_test.go # package api_test
|
|
├── mcp/
|
|
│ ├── mcp.go
|
|
│ └── mcp_test.go # package mcp_test
|
|
├── llm/
|
|
│ ├── llm.go
|
|
│ └── llm_test.go # package llm_test
|
|
├── agent/
|
|
│ ├── agent.go
|
|
│ └── agent_test.go # package agent_test
|
|
└── health/
|
|
├── health.go
|
|
└── health_test.go # package health_test
|
|
```
|
|
|
|
Tests live beside the code they verify. `grpc/tests/testutils/` is shared infrastructure only.
|
|
|
|
## testutils API
|
|
|
|
`grpc/tests/testutils/testutils.go` provides the test harness used by all sub-packages.
|
|
|
|
```go
|
|
package testutils
|
|
|
|
// Prepare initializes the full Yao runtime (DB, V8, models, scripts, etc.)
|
|
// then starts a real gRPC server on :0 (random port).
|
|
// Returns a connected grpc.ClientConn ready to create service clients.
|
|
//
|
|
// Internally calls:
|
|
// test.Prepare(t, config.Conf) — Yao runtime
|
|
// grpc.StartServer(cfg{Port:0}) — gRPC server
|
|
// grpc.Dial("127.0.0.1:port") — client connection
|
|
func Prepare(t *testing.T) *grpc.ClientConn
|
|
|
|
// Clean gracefully stops the gRPC server and tears down the Yao runtime.
|
|
// Always use with defer:
|
|
// conn := testutils.Prepare(t)
|
|
// defer testutils.Clean()
|
|
func Clean()
|
|
|
|
// Addr returns the gRPC server address "127.0.0.1:xxxxx".
|
|
func Addr() string
|
|
|
|
// ObtainAccessToken mints a token with the given scopes.
|
|
// Calls oauth.MakeAccessToken directly — no HTTP round-trip.
|
|
func ObtainAccessToken(t *testing.T, scopes ...string) string
|
|
|
|
// ObtainAccessTokenForUser mints a token for a specific user ID.
|
|
func ObtainAccessTokenForUser(t *testing.T, userID string, scopes ...string) string
|
|
|
|
// WithToken returns ctx with Bearer token in gRPC metadata.
|
|
func WithToken(ctx context.Context, token string) context.Context
|
|
|
|
// WithRefreshToken returns ctx with both Bearer and x-refresh-token metadata.
|
|
func WithRefreshToken(ctx context.Context, token, refreshToken string) context.Context
|
|
|
|
// WithSandboxMetadata returns ctx with x-sandbox-id and x-grpc-upstream metadata.
|
|
func WithSandboxMetadata(ctx context.Context, sandboxID, upstream string) context.Context
|
|
|
|
// NewClient creates a pb.YaoServiceClient from a connection.
|
|
func NewClient(conn *grpc.ClientConn) pb.YaoServiceClient
|
|
```
|
|
|
|
## How to Write a Test
|
|
|
|
### Standard pattern
|
|
|
|
Every test file follows this structure:
|
|
|
|
```go
|
|
// grpc/run/run_test.go
|
|
package run_test
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
|
|
"github.com/yaoapp/yao/grpc/pb"
|
|
"github.com/yaoapp/yao/grpc/tests/testutils"
|
|
)
|
|
|
|
func TestRun_ProcessExec(t *testing.T) {
|
|
conn := testutils.Prepare(t)
|
|
defer testutils.Clean()
|
|
|
|
client := testutils.NewClient(conn)
|
|
token := testutils.ObtainAccessToken(t, "grpc:run")
|
|
ctx := testutils.WithToken(context.Background(), token)
|
|
|
|
resp, err := client.Run(ctx, &pb.RunRequest{
|
|
Process: "utils.app.Ping",
|
|
})
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, resp.Data)
|
|
}
|
|
|
|
func TestRun_InvalidProcess(t *testing.T) {
|
|
conn := testutils.Prepare(t)
|
|
defer testutils.Clean()
|
|
|
|
client := testutils.NewClient(conn)
|
|
token := testutils.ObtainAccessToken(t, "grpc:run")
|
|
ctx := testutils.WithToken(context.Background(), token)
|
|
|
|
_, err := client.Run(ctx, &pb.RunRequest{Process: "nonexistent.process"})
|
|
assert.Error(t, err)
|
|
}
|
|
```
|
|
|
|
### Auth tests
|
|
|
|
Auth tests verify the interceptor chain through the gRPC client:
|
|
|
|
```go
|
|
// grpc/auth/guard_test.go
|
|
package auth_test
|
|
|
|
func TestAuth_NoToken_Rejected(t *testing.T) {
|
|
conn := testutils.Prepare(t)
|
|
defer testutils.Clean()
|
|
client := testutils.NewClient(conn)
|
|
|
|
_, err := client.Run(context.Background(), &pb.RunRequest{Process: "utils.app.Ping"})
|
|
st, _ := status.FromError(err)
|
|
assert.Equal(t, codes.Unauthenticated, st.Code())
|
|
}
|
|
|
|
func TestAuth_WrongScope_Denied(t *testing.T) {
|
|
conn := testutils.Prepare(t)
|
|
defer testutils.Clean()
|
|
client := testutils.NewClient(conn)
|
|
|
|
token := testutils.ObtainAccessToken(t, "grpc:mcp")
|
|
ctx := testutils.WithToken(context.Background(), token)
|
|
|
|
_, err := client.Run(ctx, &pb.RunRequest{Process: "utils.app.Ping"})
|
|
st, _ := status.FromError(err)
|
|
assert.Equal(t, codes.PermissionDenied, st.Code())
|
|
}
|
|
|
|
func TestAuth_TokenRefresh(t *testing.T) {
|
|
conn := testutils.Prepare(t)
|
|
defer testutils.Clean()
|
|
client := testutils.NewClient(conn)
|
|
|
|
// Mint an expired token + valid refresh token,
|
|
// send request with x-refresh-token metadata,
|
|
// verify response header contains x-new-access-token.
|
|
}
|
|
|
|
func TestHealthz_Public(t *testing.T) {
|
|
conn := testutils.Prepare(t)
|
|
defer testutils.Clean()
|
|
client := testutils.NewClient(conn)
|
|
|
|
resp, err := client.Healthz(context.Background(), &pb.Empty{})
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "ok", resp.Status)
|
|
}
|
|
```
|
|
|
|
### Streaming tests
|
|
|
|
```go
|
|
// grpc/llm/llm_test.go
|
|
package llm_test
|
|
|
|
func TestChatCompletionsStream(t *testing.T) {
|
|
conn := testutils.Prepare(t)
|
|
defer testutils.Clean()
|
|
client := testutils.NewClient(conn)
|
|
|
|
token := testutils.ObtainAccessToken(t, "grpc:llm")
|
|
ctx := testutils.WithToken(context.Background(), token)
|
|
|
|
stream, err := client.ChatCompletionsStream(ctx, &pb.ChatRequest{
|
|
// ... model, messages, etc.
|
|
})
|
|
assert.NoError(t, err)
|
|
|
|
var chunks int
|
|
for {
|
|
chunk, err := stream.Recv()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
assert.NoError(t, err)
|
|
chunks++
|
|
assert.NotEmpty(t, chunk.Data)
|
|
}
|
|
assert.Greater(t, chunks, 0)
|
|
}
|
|
```
|
|
|
|
```go
|
|
// grpc/agent/agent_test.go
|
|
package agent_test
|
|
|
|
func TestAgentStream(t *testing.T) {
|
|
conn := testutils.Prepare(t)
|
|
defer testutils.Clean()
|
|
client := testutils.NewClient(conn)
|
|
|
|
token := testutils.ObtainAccessToken(t, "grpc:agent")
|
|
ctx := testutils.WithToken(context.Background(), token)
|
|
|
|
stream, err := client.AgentStream(ctx, &pb.AgentRequest{
|
|
RobotID: "test-robot",
|
|
// ...
|
|
})
|
|
assert.NoError(t, err)
|
|
|
|
var chunks int
|
|
for {
|
|
chunk, err := stream.Recv()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
assert.NoError(t, err)
|
|
chunks++
|
|
// Each chunk carries JSON-serialized agent/output/message.Message
|
|
}
|
|
assert.Greater(t, chunks, 0)
|
|
}
|
|
|
|
func TestAgentStream_InvalidRobot(t *testing.T) {
|
|
conn := testutils.Prepare(t)
|
|
defer testutils.Clean()
|
|
client := testutils.NewClient(conn)
|
|
|
|
token := testutils.ObtainAccessToken(t, "grpc:agent")
|
|
ctx := testutils.WithToken(context.Background(), token)
|
|
|
|
stream, err := client.AgentStream(ctx, &pb.AgentRequest{
|
|
RobotID: "nonexistent-robot",
|
|
})
|
|
// Either err on open or first Recv returns error
|
|
if err == nil {
|
|
_, err = stream.Recv()
|
|
}
|
|
assert.Error(t, err)
|
|
}
|
|
```
|
|
|
|
## Required Test Cases
|
|
|
|
Each sub-package must cover at minimum:
|
|
|
|
| Sub-package | Required cases |
|
|
|-------------|----------------|
|
|
| `auth` | valid token / no token (Unauthenticated) / expired token + refresh / wrong scope (PermissionDenied) / Healthz skips auth |
|
|
| `health` | Healthz returns ok without token |
|
|
| `run` | valid process / nonexistent process / bad arguments |
|
|
| `shell` | valid command / command not found / timeout |
|
|
| `api` | valid proxy / 404 endpoint |
|
|
| `mcp` | MCPListTools / MCPCallTool / MCPListResources / MCPReadResource |
|
|
| `llm` | ChatCompletions (unary) / ChatCompletionsStream (multiple chunks) / invalid model |
|
|
| `agent` | AgentStream (receives message chunks) / nonexistent robot ID |
|
|
|
|
## Makefile
|
|
|
|
Add to [Makefile](../Makefile):
|
|
|
|
```makefile
|
|
TESTFOLDER_GRPC := $(shell $(GO) list ./grpc/...)
|
|
|
|
.PHONY: unit-test-grpc
|
|
unit-test-grpc:
|
|
echo "mode: count" > coverage.out
|
|
for d in $(TESTFOLDER_GRPC); do \
|
|
$(GO) test -tags $(TESTTAGS) -v -timeout=10m \
|
|
-covermode=count -coverprofile=profile.out \
|
|
-coverpkg=$$(echo $$d | sed "s/\/test$$//g") \
|
|
-skip='TestMemoryLeak|TestIsolateDisposal|TestLeak_|TestScenario_' \
|
|
$$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; \
|
|
elif grep -q "setup failed" tmp.out; then \
|
|
rm tmp.out; \
|
|
exit 1; \
|
|
elif grep -q "runtime error" 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
|
|
```
|
|
|
|
Also add `|grpc` to the `TESTFOLDER_CORE` exclude pattern so core-test does not duplicate gRPC tests.
|
|
|
|
## CI Integration
|
|
|
|
Add `grpc-test` job to `unit-test.yml` and `pr-test.yml`:
|
|
|
|
```yaml
|
|
grpc-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:
|
|
# ... standard checkout + setup (same as core-test) ...
|
|
|
|
- name: Setup ENV (SQLite)
|
|
run: |
|
|
mkdir -p ${{ github.WORKSPACE }}/../app/db
|
|
echo "YAO_DB_DRIVER=sqlite3" >> $GITHUB_ENV
|
|
echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV
|
|
|
|
- name: Run gRPC Tests
|
|
run: make unit-test-grpc
|
|
|
|
- name: Codecov Report
|
|
uses: codecov/codecov-action@v4
|
|
with:
|
|
token: ${{ secrets.CODECOV_TOKEN }}
|
|
```
|
|
|
|
Key decisions:
|
|
- SQLite only — gRPC is a transport layer, no need for MySQL matrix
|
|
- No Qdrant/Neo4j/MCP-everything services needed
|
|
- LLM/Agent streaming uses real `OPENAI_TEST_KEY` + `ANTHROPIC_API_KEY` (same secrets as agent-test job)
|
|
|
|
## Coverage
|
|
|
|
- Target: >80% per sub-package, >80% overall
|
|
- `grpc.go` (server lifecycle) covered indirectly via testutils.Prepare/Clean
|
|
- Coverage collected via `-coverprofile`, reported to Codecov
|
|
|
|
## Phase Test Schedule
|
|
|
|
Tests are written alongside implementation, not after:
|
|
|
|
| Phase | Test files | Repo |
|
|
|-------|------------|------|
|
|
| Phase 1 (auth + server) | `auth/guard_test.go`, `health/health_test.go` | yao |
|
|
| Phase 2 (handlers) | `run/run_test.go`, `shell/shell_test.go`, `api/api_test.go`, `mcp/mcp_test.go` | yao |
|
|
| Phase 3 (LLM + Agent) | `llm/llm_test.go`, `agent/agent_test.go` | yao |
|
|
| Phase 4 (Tai gateway) | Tai repo tests — gateway forwards `x-grpc-upstream`, conn cache reuse, missing metadata rejected | tai |
|
|
| Phase 5 (yao-grpc client) | `tai/grpc/grpc_test.go` — dial, method wrappers, token refresh via response metadata, `x-grpc-upstream` attachment | yao |
|
|
| Phase 6 (Device Flow) | `openapi/oauth/*_test.go` — DeviceAuthorization, device_code grant, poll pending/approved/expired | yao |
|
|
|
|
Each Phase PR must include tests for all new code. Coverage must meet threshold before merge.
|
|
|
|
## Running Tests
|
|
|
|
```bash
|
|
# All gRPC tests
|
|
make unit-test-grpc
|
|
|
|
# Single sub-package
|
|
go test -v ./grpc/auth/
|
|
|
|
# Single test
|
|
go test -v -run TestAuth_NoToken_Rejected ./grpc/auth/
|
|
```
|