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() != "

hello wazero

" { 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.GetHtml()) != "Wazero" { t.Errorf("template html = %q", rt.GetHtml()) } } // 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() != "

hello {{ name }}

" { 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() != "HI" || 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() != "

hello survivor

" { 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()) } }