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