pluginsdk/plugin/wasmguest/render_capability_test.go
Alex Dunmow b6d40ed8ac feat: bootstrap block/pluginsdk — the proto-first plugin SDK (P1)
Bootstrap the plugin-facing SDK module so the fleet migration (P2) becomes a
mechanical block/core/X -> block/pluginsdk/X import rewrite.

- abi/proto/v1: the single source-of-truth ABI proto tree, copied from the cms
  authoring source (cms/backend/abi/proto/v1) with go_package retargeted to
  git.dev.alexdunmow.com/block/pluginsdk/abi/v1;abiv1. Kills the hand-kept
  cms/core proto duplication (audit gap 4).
- abi/v1: generated Go bindings (buf generate); byte-identical to core's abi/v1
  save the embedded go_package path.
- plugin/ (registration + DI surface), plugin/wasmguest/** (transport shim,
  caps stubs, bnwasm db driver, testdata fixtures + golden .pb), and the
  guest-facing type packages: blocks (+builtin/shared/tags), templates
  (+pongo/bn), auth, settings, content, gating, crypto, rbac, video, ai,
  subscriptions, menus, datasources. Internal imports rewritten core ->
  pluginsdk; zero block/core references remain.
- README/AGENTS(+CLAUDE symlink)/Makefile: proto is the contract, Go is one
  binding; no replace directives; templates/bn is a synced copy authored in cms.

Spec: cms docs/superpowers/specs/2026-07-07-proto-first-plugin-sdk-design.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:51:00 +08:00

221 lines
7.9 KiB
Go

package wasmguest
import (
"context"
"errors"
"strings"
"testing"
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/blocks"
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
"git.dev.alexdunmow.com/block/pluginsdk/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())
}
}