core/plugin/wasmguest/render_capability_test.go
Alex Dunmow 04991295cf feat(abi,blocks): ninjatpl rendering as a host capability (powered blocks + tag/filter callbacks)
pongo2 stays host-side and is never compiled into a guest; plugins render
templates by handing the host a {template, data} pair, and the host calls
back into the free guest instance for plugin-declared tags/filters.

ABI (additive, buf-breaking clean):
- RenderBlockResponse gains a `powered` PoweredBlock{template, data_json};
  a block returns EITHER html OR powered.
- New hooks HOOK_RENDER_TAG (10) / HOOK_APPLY_FILTER (11) with
  RenderTag{Request,Response} and ApplyFilter{Request,Response}.
- PluginManifest gains repeated declared_tags / declared_filters (31/32).

Guest SDK (core/blocks, core/plugin/wasmguest):
- blocks.PoweredBlock(template, data) / DecodePoweredBlock: NUL-sentinel
  marker so BlockFunc's string signature is unchanged (smallest additive
  change — no ripple to existing blocks or the host guest-side).
- blocks.RegisterTag / RegisterFilter (+ RenderContext = context.Context)
  write a package-level registry; runRegister resets it per registration
  for deterministic DESCRIBE + dispatch.
- DESCRIBE emits declared_tags/filters; dispatch handles RENDER_TAG /
  APPLY_FILTER (fn errors → response.error; panics → AbiError INTERNAL,
  instance stays callable).

Docs: core/docs/wasm-abi.md gains the render-as-a-host-capability model,
the powered-block flow (re-entrancy-free), the plugin API, and the
HOST-SIDE CONTRACT the cms phase implements.

Tests: unit round-trips for powered/RENDER_TAG/APPLY_FILTER (dispatch +
error + unknown + panic + per-guest registry isolation) plus a real
wazero round-trip through the compiled fixture module. Verified no
guest-reachable package imports pongo2 (go list -deps on the wasip1
fixture build is clean).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 02:36:39 +08:00

221 lines
7.8 KiB
Go

package wasmguest
import (
"context"
"errors"
"strings"
"testing"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"git.dev.alexdunmow.com/block/core/blocks"
"git.dev.alexdunmow.com/block/core/plugin"
"git.dev.alexdunmow.com/block/core/templates"
"google.golang.org/protobuf/proto"
)
// tagFilterRegistration registers a powered block plus a custom tag and
// filter, exercising the render-as-a-host-capability surface (RENDER_TAG,
// APPLY_FILTER, powered RENDER_BLOCK, and DESCRIBE declared_tags/filters).
func tagFilterRegistration() plugin.PluginRegistration {
return plugin.PluginRegistration{
Name: "capfx",
Version: "0.1.0",
Register: func(_ templates.TemplateRegistry, br blocks.BlockRegistry) error {
br.Register(blocks.BlockMeta{Key: "hero", Title: "Hero", Category: blocks.CategoryContent},
func(ctx context.Context, content map[string]any) string {
title, _ := content["title"].(string)
// Powered: return template + data; the host renders it.
return blocks.PoweredBlock("<h1>{{ title }}</h1>", map[string]any{"title": title})
})
blocks.RegisterTag("greet", func(args map[string]any, rctx blocks.RenderContext) (string, error) {
who, _ := args["who"].(string)
if who == "boom" {
panic("tag kaboom")
}
if who == "err" {
return "", errors.New("greet failed")
}
// Reads render context via the standard accessors.
tpl := blocks.GetTemplateKey(rctx)
return "hi " + who + " on " + tpl, nil
})
blocks.RegisterFilter("upper", func(input string, args map[string]any) (string, error) {
if input == "err" {
return "", errors.New("upper failed")
}
return strings.ToUpper(input), nil
})
return nil
},
}
}
func TestDescribeEmitsDeclaredTagsAndFilters(t *testing.T) {
g := newGuest(tagFilterRegistration())
resp := invokeHook(t, g, 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: %v", err)
}
m := dr.GetManifest()
if got := m.GetDeclaredTags(); len(got) != 1 || got[0] != "greet" {
t.Errorf("declared_tags = %v, want [greet]", got)
}
if got := m.GetDeclaredFilters(); len(got) != 1 || got[0] != "upper" {
t.Errorf("declared_filters = %v, want [upper]", got)
}
}
// TestDescribeTagRegistryIsPerGuest proves runRegister's reset isolates the
// package-level tag/filter registry per installed registration: a tag-free
// registration must describe zero tags even after a prior tag-registering one.
func TestDescribeTagRegistryIsPerGuest(t *testing.T) {
_ = newGuest(tagFilterRegistration()) // populates the registry
g := newGuest(fixtureRegistration()) // fixtureRegistration registers no tags
resp := invokeHook(t, g, abiv1.Hook_HOOK_DESCRIBE, &abiv1.DescribeRequest{HostAbiVersion: 1})
dr := &abiv1.DescribeResponse{}
if err := proto.Unmarshal(resp.GetPayload(), dr); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got := dr.GetManifest().GetDeclaredTags(); len(got) != 0 {
t.Errorf("declared_tags = %v, want empty (registry not reset per guest)", got)
}
}
func TestPoweredBlockRoundTrip(t *testing.T) {
g := newGuest(tagFilterRegistration())
resp := invokeHook(t, g, abiv1.Hook_HOOK_RENDER_BLOCK, &abiv1.RenderBlockRequest{
BlockKey: "capfx:hero",
ContentJson: []byte(`{"title":"Welcome"}`),
})
if resp.GetError() != nil {
t.Fatalf("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() != "" {
t.Errorf("html = %q, want empty (powered)", rb.GetHtml())
}
p := rb.GetPowered()
if p == nil {
t.Fatal("nil Powered result")
}
if p.GetTemplate() != "<h1>{{ title }}</h1>" {
t.Errorf("template = %q", p.GetTemplate())
}
if !strings.Contains(string(p.GetDataJson()), `"title":"Welcome"`) {
t.Errorf("data_json = %s", p.GetDataJson())
}
}
func TestRenderTagRoundTrip(t *testing.T) {
g := newGuest(tagFilterRegistration())
resp := invokeHook(t, g, abiv1.Hook_HOOK_RENDER_TAG, &abiv1.RenderTagRequest{
TagName: "greet",
ArgsJson: []byte(`{"who":"sam"}`),
RenderContext: &abiv1.RenderContext{TemplateKey: "landing"},
})
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: %v", err)
}
if tr.GetHtml() != "hi sam on landing" || tr.GetError() != "" {
t.Errorf("html=%q error=%q", tr.GetHtml(), tr.GetError())
}
}
func TestRenderTagFnErrorSurfacesInResponse(t *testing.T) {
g := newGuest(tagFilterRegistration())
resp := invokeHook(t, g, abiv1.Hook_HOOK_RENDER_TAG, &abiv1.RenderTagRequest{
TagName: "greet", ArgsJson: []byte(`{"who":"err"}`),
})
if resp.GetError() != nil {
t.Fatalf("unexpected AbiError: %v", resp.GetError())
}
tr := &abiv1.RenderTagResponse{}
if err := proto.Unmarshal(resp.GetPayload(), tr); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if tr.GetHtml() != "" || !strings.Contains(tr.GetError(), "greet failed") {
t.Errorf("html=%q error=%q, want error carrying 'greet failed'", tr.GetHtml(), tr.GetError())
}
}
func TestRenderTagUnknownIsUnimplemented(t *testing.T) {
g := newGuest(tagFilterRegistration())
resp := invokeHook(t, g, abiv1.Hook_HOOK_RENDER_TAG, &abiv1.RenderTagRequest{TagName: "nope"})
if resp.GetError().GetCode() != abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED {
t.Fatalf("error = %v, want UNIMPLEMENTED", resp.GetError())
}
}
func TestRenderTagPanicRecoversAndStaysCallable(t *testing.T) {
g := newGuest(tagFilterRegistration())
resp := invokeHook(t, g, abiv1.Hook_HOOK_RENDER_TAG, &abiv1.RenderTagRequest{
TagName: "greet", ArgsJson: []byte(`{"who":"boom"}`),
})
if resp.GetError().GetCode() != abiv1.AbiErrorCode_ABI_ERROR_CODE_INTERNAL {
t.Fatalf("error = %v, want INTERNAL", resp.GetError())
}
if !strings.Contains(resp.GetError().GetMessage(), "tag kaboom") {
t.Errorf("message = %q, want panic value", resp.GetError().GetMessage())
}
// Guest stays callable after a recovered tag panic.
resp2 := invokeHook(t, g, abiv1.Hook_HOOK_RENDER_TAG, &abiv1.RenderTagRequest{
TagName: "greet", ArgsJson: []byte(`{"who":"again"}`),
})
if resp2.GetError() != nil {
t.Fatalf("post-panic tag failed: %v", resp2.GetError())
}
}
func TestApplyFilterRoundTrip(t *testing.T) {
g := newGuest(tagFilterRegistration())
resp := invokeHook(t, g, abiv1.Hook_HOOK_APPLY_FILTER, &abiv1.ApplyFilterRequest{
FilterName: "upper", 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: %v", err)
}
if fr.GetOutput() != "HELLO" || fr.GetError() != "" {
t.Errorf("output=%q error=%q", fr.GetOutput(), fr.GetError())
}
}
func TestApplyFilterFnErrorSurfacesInResponse(t *testing.T) {
g := newGuest(tagFilterRegistration())
resp := invokeHook(t, g, abiv1.Hook_HOOK_APPLY_FILTER, &abiv1.ApplyFilterRequest{
FilterName: "upper", Input: "err",
})
if resp.GetError() != nil {
t.Fatalf("unexpected AbiError: %v", resp.GetError())
}
fr := &abiv1.ApplyFilterResponse{}
if err := proto.Unmarshal(resp.GetPayload(), fr); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if fr.GetOutput() != "" || !strings.Contains(fr.GetError(), "upper failed") {
t.Errorf("output=%q error=%q", fr.GetOutput(), fr.GetError())
}
}
func TestApplyFilterUnknownIsUnimplemented(t *testing.T) {
g := newGuest(tagFilterRegistration())
resp := invokeHook(t, g, abiv1.Hook_HOOK_APPLY_FILTER, &abiv1.ApplyFilterRequest{FilterName: "nope"})
if resp.GetError().GetCode() != abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED {
t.Fatalf("error = %v, want UNIMPLEMENTED", resp.GetError())
}
}