351 lines
12 KiB
Go
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())
|
|
}
|
|
}
|