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>
221 lines
7.8 KiB
Go
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())
|
|
}
|
|
}
|