pluginsdk/plugin/wasmguest/wasmhost_test.go
Alex Dunmow 80328eddb1 feat(wasmguest): PageDocument envelope; renderTemplate returns TemplateDocument
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 13:27:05 +08:00

351 lines
12 KiB
Go

package wasmguest
// Throwaway wazero host: proves the compiled fixture module honors the ABI
// end to end (WO-WZ-002 acceptance) without the real cms host WO. It builds
// testdata/fixture in reactor mode, instantiates it, and drives bn_alloc /
// bn_invoke / bn_free exactly as core/docs/wasm-abi.md specifies.
import (
"context"
"encoding/binary"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/api"
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
"google.golang.org/protobuf/proto"
)
// buildFixture compiles testdata/fixture to a reactor-mode wasm module.
func buildFixture(t *testing.T) string {
t.Helper()
out := filepath.Join(t.TempDir(), "fixture.wasm")
cmd := exec.Command("go", "build", "-buildmode=c-shared", "-o", out, "./testdata/fixture")
cmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm")
if b, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("build fixture: %v\n%s", err, b)
}
info, err := os.Stat(out)
if err != nil {
t.Fatalf("stat fixture: %v", err)
}
t.Logf("fixture.wasm size: %d bytes (%.1f MiB)", info.Size(), float64(info.Size())/(1<<20))
return out
}
type wasmInstance struct {
mod api.Module
ctx context.Context
}
func instantiateFixture(t *testing.T) *wasmInstance {
t.Helper()
wasmPath := buildFixture(t)
ctx := context.Background()
r := wazero.NewRuntime(ctx)
t.Cleanup(func() { _ = r.Close(ctx) })
wasi_snapshot_preview1.MustInstantiate(ctx, r)
// The guest now imports blockninja.host_call (capability stubs, WO-WZ-003),
// so the host must export it or instantiation fails.
installBlockninjaHost(t, ctx, r)
wasmBytes, err := os.ReadFile(wasmPath)
if err != nil {
t.Fatalf("read module: %v", err)
}
// Reactor contract: no _start; run _initialize once per instance before
// any bn_invoke.
mod, err := r.InstantiateWithConfig(ctx, wasmBytes,
wazero.NewModuleConfig().WithStartFunctions("_initialize").WithName("fixture"))
if err != nil {
t.Fatalf("instantiate: %v", err)
}
for _, export := range []string{"bn_alloc", "bn_invoke", "bn_free"} {
if mod.ExportedFunction(export) == nil {
t.Fatalf("module does not export %s", export)
}
}
return &wasmInstance{mod: mod, ctx: ctx}
}
// invoke drives one bn_invoke round trip through guest memory.
func (w *wasmInstance) invoke(t *testing.T, hook abiv1.Hook, payload proto.Message) *abiv1.InvokeResponse {
t.Helper()
payloadBytes, err := proto.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
req, err := proto.Marshal(&abiv1.InvokeRequest{Hook: hook, Payload: payloadBytes})
if err != nil {
t.Fatalf("marshal envelope: %v", err)
}
alloc := w.mod.ExportedFunction("bn_alloc")
res, err := alloc.Call(w.ctx, uint64(len(req)))
if err != nil {
t.Fatalf("bn_alloc: %v", err)
}
ptr := uint32(res[0])
if ptr == 0 {
t.Fatal("bn_alloc returned 0")
}
if !w.mod.Memory().Write(ptr, req) {
t.Fatalf("write request at %d (len %d) out of range", ptr, len(req))
}
res, err = w.mod.ExportedFunction("bn_invoke").Call(w.ctx, uint64(hook), uint64(ptr), uint64(len(req)))
if err != nil {
t.Fatalf("bn_invoke: %v", err)
}
packed := res[0]
if packed == 0 {
t.Fatal("bn_invoke returned packed 0 (guest could not produce an envelope)")
}
respPtr := uint32(packed >> 32)
respLen := uint32(packed)
respBytes, ok := w.mod.Memory().Read(respPtr, respLen)
if !ok {
t.Fatalf("read response at %d (len %d) out of range", respPtr, respLen)
}
resp := &abiv1.InvokeResponse{}
if err := proto.Unmarshal(respBytes, resp); err != nil {
t.Fatalf("unmarshal InvokeResponse: %v", err)
}
// Host-driven release of the request buffer (guest also self-releases;
// bn_free must tolerate both).
if _, err := w.mod.ExportedFunction("bn_free").Call(w.ctx, uint64(ptr)); err != nil {
t.Fatalf("bn_free: %v", err)
}
return resp
}
func TestWasmFixtureDescribeRoundTrip(t *testing.T) {
if testing.Short() {
t.Skip("compiles a wasm module; skipped in -short")
}
w := instantiateFixture(t)
resp := w.invoke(t, abiv1.Hook_HOOK_DESCRIBE, &abiv1.DescribeRequest{HostAbiVersion: 1})
if resp.GetError() != nil {
t.Fatalf("DESCRIBE error: %v", resp.GetError())
}
dr := &abiv1.DescribeResponse{}
if err := proto.Unmarshal(resp.GetPayload(), dr); err != nil {
t.Fatalf("unmarshal DescribeResponse: %v", err)
}
m := dr.GetManifest()
t.Logf("manifest: name=%q version=%q abi=%d blocks=%d templates=%v admin_pages=%d",
m.GetName(), m.GetVersion(), m.GetAbiVersion(), len(m.GetBlocks()), m.GetTemplateKeys(), len(m.GetAdminPages()))
if m.GetName() != "wasmfixture" || m.GetVersion() != "0.0.1" || m.GetAbiVersion() != 1 {
t.Errorf("manifest identity = %q/%q/abi %d", m.GetName(), m.GetVersion(), m.GetAbiVersion())
}
if len(m.GetBlocks()) != 1 || m.GetBlocks()[0].GetKey() != "wasmfixture:greeting" {
t.Errorf("blocks = %v", m.GetBlocks())
}
if len(m.GetTemplateKeys()) != 1 || m.GetTemplateKeys()[0] != "wasmfixture-page" {
t.Errorf("template_keys = %v", m.GetTemplateKeys())
}
if len(m.GetAdminPages()) != 1 || m.GetAdminPages()[0].GetRoute() != "/admin/wasmfixture" {
t.Errorf("admin_pages = %v", m.GetAdminPages())
}
// Same instance renders a block after DESCRIBE.
resp = w.invoke(t, abiv1.Hook_HOOK_RENDER_BLOCK, &abiv1.RenderBlockRequest{
BlockKey: "wasmfixture:greeting",
ContentJson: []byte(`{"name":"wazero"}`),
})
if resp.GetError() != nil {
t.Fatalf("RENDER_BLOCK error: %v", resp.GetError())
}
rb := &abiv1.RenderBlockResponse{}
if err := proto.Unmarshal(resp.GetPayload(), rb); err != nil {
t.Fatalf("unmarshal RenderBlockResponse: %v", err)
}
if rb.GetHtml() != "<p>hello wazero</p>" {
t.Errorf("html = %q", rb.GetHtml())
}
// Template render through the same instance.
resp = w.invoke(t, abiv1.Hook_HOOK_RENDER_TEMPLATE, &abiv1.RenderTemplateRequest{
TemplateKey: "wasmfixture-page",
DocJson: []byte(`{"title":"Wazero"}`),
})
if resp.GetError() != nil {
t.Fatalf("RENDER_TEMPLATE error: %v", resp.GetError())
}
rt := &abiv1.RenderTemplateResponse{}
if err := proto.Unmarshal(resp.GetPayload(), rt); err != nil {
t.Fatalf("unmarshal RenderTemplateResponse: %v", err)
}
if string(rt.GetDocument().GetBodyHtml()) != "<!doctype html><title>Wazero</title>" {
t.Errorf("template body_html = %q", rt.GetDocument().GetBodyHtml())
}
}
// TestWasmFixtureTagFilterPoweredRoundTrip drives the render-as-a-host-
// capability surface through a REAL compiled wasm module: DESCRIBE must
// surface the plugin's declared tags/filters, a powered block must return a
// {template,data} result (not final HTML), and HOOK_RENDER_TAG /
// HOOK_APPLY_FILTER must dispatch to the plugin's registered fns.
func TestWasmFixtureTagFilterPoweredRoundTrip(t *testing.T) {
if testing.Short() {
t.Skip("compiles a wasm module; skipped in -short")
}
w := instantiateFixture(t)
// DESCRIBE surfaces declared_tags / declared_filters.
resp := w.invoke(t, abiv1.Hook_HOOK_DESCRIBE, &abiv1.DescribeRequest{HostAbiVersion: 1})
if resp.GetError() != nil {
t.Fatalf("DESCRIBE error: %v", resp.GetError())
}
dr := &abiv1.DescribeResponse{}
if err := proto.Unmarshal(resp.GetPayload(), dr); err != nil {
t.Fatalf("unmarshal DescribeResponse: %v", err)
}
m := dr.GetManifest()
if got := m.GetDeclaredTags(); len(got) != 1 || got[0] != "shout" {
t.Errorf("declared_tags = %v, want [shout]", got)
}
if got := m.GetDeclaredFilters(); len(got) != 1 || got[0] != "exclaim" {
t.Errorf("declared_filters = %v, want [exclaim]", got)
}
// A powered block returns {template,data}, not final HTML.
resp = w.invoke(t, abiv1.Hook_HOOK_RENDER_BLOCK, &abiv1.RenderBlockRequest{
BlockKey: "wasmfixture:greeting",
ContentJson: []byte(`{"name":"wazero","powered":true}`),
})
if resp.GetError() != nil {
t.Fatalf("powered RENDER_BLOCK error: %v", resp.GetError())
}
rb := &abiv1.RenderBlockResponse{}
if err := proto.Unmarshal(resp.GetPayload(), rb); err != nil {
t.Fatalf("unmarshal RenderBlockResponse: %v", err)
}
if rb.GetHtml() != "" {
t.Errorf("powered block set html = %q, want empty", rb.GetHtml())
}
p := rb.GetPowered()
if p == nil {
t.Fatal("powered block returned nil Powered result")
}
if p.GetTemplate() != "<p>hello {{ name }}</p>" {
t.Errorf("powered template = %q", p.GetTemplate())
}
if !strings.Contains(string(p.GetDataJson()), `"name":"wazero"`) {
t.Errorf("powered data_json = %s", p.GetDataJson())
}
// HOOK_RENDER_TAG dispatches to the registered tag fn (fresh invoke —
// no re-entrancy; RENDER_BLOCK already returned).
resp = w.invoke(t, abiv1.Hook_HOOK_RENDER_TAG, &abiv1.RenderTagRequest{
TagName: "shout",
ArgsJson: []byte(`{"msg":"hi"}`),
})
if resp.GetError() != nil {
t.Fatalf("RENDER_TAG error: %v", resp.GetError())
}
tr := &abiv1.RenderTagResponse{}
if err := proto.Unmarshal(resp.GetPayload(), tr); err != nil {
t.Fatalf("unmarshal RenderTagResponse: %v", err)
}
if tr.GetHtml() != "<strong>HI</strong>" || tr.GetError() != "" {
t.Errorf("tag result html=%q error=%q", tr.GetHtml(), tr.GetError())
}
// HOOK_APPLY_FILTER dispatches to the registered filter fn.
resp = w.invoke(t, abiv1.Hook_HOOK_APPLY_FILTER, &abiv1.ApplyFilterRequest{
FilterName: "exclaim",
Input: "hello",
})
if resp.GetError() != nil {
t.Fatalf("APPLY_FILTER error: %v", resp.GetError())
}
fr := &abiv1.ApplyFilterResponse{}
if err := proto.Unmarshal(resp.GetPayload(), fr); err != nil {
t.Fatalf("unmarshal ApplyFilterResponse: %v", err)
}
if fr.GetOutput() != "hello!" || fr.GetError() != "" {
t.Errorf("filter result output=%q error=%q", fr.GetOutput(), fr.GetError())
}
// Unknown tag → UNIMPLEMENTED AbiError.
resp = w.invoke(t, abiv1.Hook_HOOK_RENDER_TAG, &abiv1.RenderTagRequest{TagName: "nope"})
if resp.GetError().GetCode() != abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED {
t.Errorf("unknown tag error = %v, want UNIMPLEMENTED", resp.GetError())
}
}
func TestWasmFixturePanicReturnsAbiErrorAndInstanceSurvives(t *testing.T) {
if testing.Short() {
t.Skip("compiles a wasm module; skipped in -short")
}
w := instantiateFixture(t)
resp := w.invoke(t, abiv1.Hook_HOOK_RENDER_BLOCK, &abiv1.RenderBlockRequest{
BlockKey: "wasmfixture:greeting",
ContentJson: []byte(`{"boom":true}`),
})
e := resp.GetError()
if e.GetCode() != abiv1.AbiErrorCode_ABI_ERROR_CODE_INTERNAL {
t.Fatalf("error = %v, want INTERNAL", e)
}
if !strings.Contains(e.GetMessage(), "fixture kaboom") {
t.Errorf("error message %q does not carry the panic value", e.GetMessage())
}
t.Logf("panic surfaced as AbiError: code=%s message=%q", e.GetCode(), e.GetMessage())
// The instance must remain callable after the recovered panic.
resp = w.invoke(t, abiv1.Hook_HOOK_RENDER_BLOCK, &abiv1.RenderBlockRequest{
BlockKey: "wasmfixture:greeting",
ContentJson: []byte(`{"name":"survivor"}`),
})
if resp.GetError() != nil {
t.Fatalf("post-panic render error: %v", resp.GetError())
}
rb := &abiv1.RenderBlockResponse{}
if err := proto.Unmarshal(resp.GetPayload(), rb); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if rb.GetHtml() != "<p>hello survivor</p>" {
t.Errorf("post-panic html = %q", rb.GetHtml())
}
t.Log("instance still callable after panic")
}
func TestWasmFixtureUnknownRequestPointerIsDecodeError(t *testing.T) {
if testing.Short() {
t.Skip("compiles a wasm module; skipped in -short")
}
w := instantiateFixture(t)
// Bypass bn_alloc: the guest must refuse pointers it did not hand out.
req, _ := proto.Marshal(&abiv1.InvokeRequest{Hook: abiv1.Hook_HOOK_DESCRIBE})
res, err := w.mod.ExportedFunction("bn_invoke").Call(w.ctx,
uint64(abiv1.Hook_HOOK_DESCRIBE), uint64(binary.MaxVarintLen64), uint64(len(req)))
if err != nil {
t.Fatalf("bn_invoke: %v", err)
}
packed := res[0]
if packed == 0 {
t.Fatal("packed 0")
}
respBytes, ok := w.mod.Memory().Read(uint32(packed>>32), uint32(packed))
if !ok {
t.Fatal("read response out of range")
}
resp := &abiv1.InvokeResponse{}
if err := proto.Unmarshal(respBytes, resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp.GetError().GetCode() != abiv1.AbiErrorCode_ABI_ERROR_CODE_DECODE {
t.Fatalf("error = %v, want DECODE", resp.GetError())
}
}