PPysolate SpineREAD THE CODE · UNDERSTAND THE SYSTEM
课程与章节
CODE READING / 07

用测试核对普通执行

runner_test.go

本章目录
配套饭点图解 · 1 节(按需展开)

这章回答一个具体问题:真实的 spine.wasm 接到 Python 源码后,普通 Runner.Run 能否把值、异常、stdout、取消和状态隔离交付到正确边界?

调用者是 Go 测试;初始状态是 dist/spine.wasm、一个带 lookup 的 Manifest,以及尚未执行的 Runner。最终交付物是 Output,或者包含 Guest/Host 原因的错误。测试故意读取真实 artifact;artifact 缺失时失败,不用替身,也不跳过。

先把测试局面搭起来

测试把一个 Go 函数放进当前的具名工具清单。函数从 JSON 参数取 key,用不同 key 制造成功、null、空错误和带文本错误;calls 记录 Host 是否真的被调用。这里的 Manifest{"lookup": {Call: ...}} 很重要:Python 通过具名函数 lookup(...) 进入这张清单,而不是旧式的工具名字符串调用。

runner_test.go · L1–L38 · 打开完整源码

package spine

import (
	"context"
	"encoding/json"
	"errors"
	"os"
	"strings"
	"testing"
	"time"
)

// Deliberately fail if the real artifact is missing: no native substitute or skip.
func TestRealGuest(t *testing.T) {
	wasm, err := os.ReadFile("dist/spine.wasm")
	if err != nil {
		t.Fatal(err)
	}
	calls := 0
	manifest := Manifest{"lookup": {Call: func(ctx context.Context, args json.RawMessage) (any, error) {
		calls++
		var a struct {
			Key string `json:"key"`
		}
		if err := json.Unmarshal(args, &a); err != nil {
			return nil, err
		}
		if a.Key == "empty-error" {
			return nil, errors.New("")
		}
		if a.Key == "null" {
			return nil, nil
		}
		if a.Key == "price" {
			return 21, nil
		}
		return nil, errors.New("missing key: " + a.Key)
	}}}

TestRealGuest 建立 context.Background(),用 New 创建 Runner,并在测试结束时关闭它。第 45 行把调用者手里的 manifest 改成空 ToolSpec,用来验证 Runner 已经拥有自己的副本,而不是继续读取外部 map 的变化。

一个表驱动循环覆盖哪些边界

测试表把“局面”和“预期观察”放在一起:源码、成功值或错误片段、以及应有的 Host 调用次数。这样每个子测试都从同一个 Runner 进入 Run,但只用自己的 Python 源码和 inputs。

runner_test.go · L39–L95 · 打开完整源码

	ctx := context.Background()
	r, err := New(ctx, wasm, manifest)
	if err != nil {
		t.Fatal(err)
	}
	defer r.Close(ctx)
	manifest["lookup"] = ToolSpec{} // Runner owns a copy of the caller's manifest.
	cases := []struct {
		name, source, value, failure string
		calls                        int
	}{
		{"python", `import sys; result = {"answer": 6 * 7, "version": sys.version_info[:2]}`, `{"answer":42,"version":[3,14]}`, "", 0},
		{"host", `result = lookup(key="price") * inputs["quantity"]`, `42`, "", 1},
		{"null tool result", `result = lookup(key="null")`, `null`, "", 1},
		{"caught tool error", "try:\n    lookup(key='absent')\nexcept RuntimeError as e:\n    result = str(e)", `"missing key: absent"`, "", 1},
		{"uncaught tool error", `result = lookup(key="absent")`, "", "RuntimeError: missing key: absent", 1},
		{"empty tool error", `result = lookup(key="empty-error")`, "", "RuntimeError", 1},
		{"malformed tool request", `from _spine import call; import json; result = "error" in json.loads(call("{"))`, `true`, "", 0},
		{"unmanifested function", `result = delete(key="price")`, "", "NameError", 0},
		{"unknown tool request", `from _spine import call; import json; result = json.loads(call('{"tool":"delete","args":{}}'))["error"]`, `"unknown tool: delete"`, "", 0},
		{"python error", `result = 1 / 0`, "", "ZeroDivisionError", 0},
		{"syntax error", `result = (`, "", "SyntaxError", 0},
		{"non JSON result", `result = {1, 2}`, "", "TypeError", 0},
		{"private state write", `import builtins; builtins.secret = 123; result = 1`, `1`, "", 0},
		{"private state read", `import builtins; result = hasattr(builtins, "secret")`, `false`, "", 0},
		{"no host filesystem", "try:\n    open('/etc/passwd').read()\nexcept OSError:\n    result = 'denied'", `"denied"`, "", 0},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			before := calls
			out, err := r.Run(ctx, tc.source, map[string]any{"quantity": 2})
			if calls-before != tc.calls {
				t.Fatalf("tool calls = %d, want %d", calls-before, tc.calls)
			}
			if tc.failure != "" {
				if err == nil || !strings.Contains(err.Error(), tc.failure) {
					t.Fatalf("got %v; want %q", err, tc.failure)
				}
			} else {
				if err != nil {
					t.Fatal(err)
				}
				var got, want any
				if err := json.Unmarshal(out.Value, &got); err != nil {
					t.Fatal(err)
				}
				if err := json.Unmarshal([]byte(tc.value), &want); err != nil {
					t.Fatal(err)
				}
				g, _ := json.Marshal(got)
				w, _ := json.Marshal(want)
				if string(g) != string(w) {
					t.Fatalf("got %s; want %s", g, w)
				}
			}
		})
	}

循环先记下调用计数,再运行 r.Run(ctx, tc.source, ...)。成功时,测试把 out.Value 和期望值都解码成 JSON,再重新编码后比较;因此断言的是 JSON 数据,而不是空格或字段排列。失败时只要求错误包含表中的边界文本,例如 RuntimeError、SyntaxError 或 ZeroDivisionError,不把未承诺的完整包装格式固定下来。

这些 case 各自钉住一条交付规则:

  • Python 自己计算的值不应触发 Host;具名 lookup 才产生一次调用。
  • Host 返回 nil, nil 时,Guest 仍能交付 JSON null。
  • Python 能捕获 Host 错误;未捕获时错误离开本轮 Run。
  • Host 返回空错误文本时,错误类型仍不能丢失。
  • 直接请求格式错误、请求未知工具、调用未注册的 Python 名称,都不能伪装成成功工具调用。
  • Python 的语法错误、运行时错误和不可 JSON 化的结果分别停在对应边界。
  • 一轮写入 builtins.secret 后,下一轮读不到它;打开 /etc/passwd 也只能观察到被 Guest 环境拒绝。

calls-before 的断言补上了结果断言看不到的部分:结果看起来正确时,不能因此推断 Host 被调用了正确次数。

stdout、截止时间和下一轮

正常 case 结束后,测试单独观察 stdout 和取消后的可复用性。它不靠 sleep 猜测 worker 什么时候结束,而是让 Run 直接返回,并检查 context 的真实状态。

runner_test.go · L96–L115 · 打开完整源码

	t.Run("stdout", func(t *testing.T) {
		out, err := r.Run(ctx, "print('hello'); result = 42", nil)
		if err != nil || out.Stdout != "hello\n" {
			t.Fatalf("out=%+v err=%v", out, err)
		}
	})
	t.Run("deadline", func(t *testing.T) {
		deadline, cancel := context.WithTimeout(ctx, 2*time.Second)
		defer cancel()
		_, err := r.Run(deadline, "while True: pass", nil)
		if err == nil || deadline.Err() == nil {
			t.Fatalf("expected deadline, got %v", err)
		}
		out, err := r.Run(ctx, "result = 7", nil)
		if err != nil || string(out.Value) != "7" {
			t.Fatalf("run after cancellation: %+v, %v", out, err)
		}
	})
}

stdout case 要求 Python 的 print 产生精确的 hello\n,同时结果仍是 42。deadline case 用两秒 timeout 执行无限循环:既要求返回错误,也要求 deadline context 自己已经记录错误。随后用同一个 Runner 执行 result = 7,证明取消的一轮没有让 Runner 永久失效。

这组断言的边界很窄:它证明本测试里的取消可以返回并继续下一轮,不声称所有 worker 都在错误返回瞬间已经退出;资源收尾仍由 Runner 的关闭和运行生命周期负责。

Manifest 入口先拒绝非法清单

最后测试的不是 Python,而是 New 的输入门槛。每个 manifest 都只有一个故意不合格的条目。

runner_test.go · L116–L135 · 打开完整源码

func TestManifestValidation(t *testing.T) {
	noop := func(context.Context, json.RawMessage) (any, error) { return nil, nil }
	for _, tc := range []struct {
		name     string
		manifest Manifest
	}{
		{"invalid identifier", Manifest{"not-valid": {Call: noop}}},
		{"Python keyword", Manifest{"class": {Call: noop}}},
		{"reserved input", Manifest{"inputs": {Call: noop}}},
		{"internal helper", Manifest{"_spine_prepare": {Call: noop}}},
		{"missing implementation", Manifest{"lookup": {}}},
	} {
		t.Run(tc.name, func(t *testing.T) {
			if r, err := New(context.Background(), nil, tc.manifest); err == nil {
				r.Close(context.Background())
				t.Fatal("expected manifest validation error")
			}
		})
	}
}

非法 Go/Python 标识符、Python 关键字、保留输入名、内部 helper 名,以及缺少 Call 实现的 ToolSpec,都必须使 New 返回错误。测试只断言“没有错误就失败”,不依赖某个具体错误字符串;这是因为这里要维护的是验证存在,而不是公开一套错误文案。

读完后回到哪里

回到 Runner:创建与运行 看 Run 怎样把这些输入送进 Guest,再到 Host 工具边界 对照请求和响应的字节边界。读完本章应能回答:

  1. 哪些结果证明 Python 没有越过 Host 边界?
  2. 为什么测试同时比较 Output.Value、错误和工具调用次数?
  3. 取消后“Runner 可继续使用”与“所有 worker 已退出”分别由什么证据支持?