pluginsdk/plugin/wasmguest/dispatch_test.go
Alex Dunmow 80328eddb1 feat(wasmguest): PageDocument envelope; renderTemplate returns TemplateDocument
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 13:27:05 +08:00

454 lines
16 KiB
Go

package wasmguest
import (
"context"
"encoding/json"
"fmt"
"io"
"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"
)
// --- test fixture registration ---
type textComponent string
func (c textComponent) Render(_ context.Context, w io.Writer) error {
_, err := io.WriteString(w, string(c))
return err
}
func fixtureRegistration() plugin.PluginRegistration {
return plugin.PluginRegistration{
Name: "fixture",
Version: "0.1.0",
Dependencies: []plugin.Dependency{
{Plugin: "other", MinVersion: "1.2.3", Required: true},
},
Register: func(tr templates.TemplateRegistry, br blocks.BlockRegistry) error {
br.Register(blocks.BlockMeta{
Key: "hello",
Title: "Hello Block",
Description: "Says hello",
Category: blocks.CategoryContent,
}, func(ctx context.Context, content map[string]any) string {
if boom, _ := content["boom"].(bool); boom {
panic("kaboom")
}
name, _ := content["name"].(string)
page := blocks.GetCurrentPage(ctx)
pageSlug := ""
if page != nil {
pageSlug = page.Slug
}
return fmt.Sprintf("<p>hello %s on %s (tpl %s)</p>", name, pageSlug, blocks.GetTemplateKey(ctx))
})
br.RegisterTemplateOverride("landing", "hello", func(ctx context.Context, content map[string]any) string {
return "<p>override hello</p>"
})
tr.RegisterSystemTemplate(templates.SystemTemplateMeta{Key: "fixture-sys", Title: "Fixture Theme"})
if err := tr.Register("fixture-tpl", func(ctx context.Context, doc map[string]any) templates.HTMLComponent {
title, _ := doc["title"].(string)
return textComponent("<html>" + title + "</html>")
}); err != nil {
return err
}
return tr.RegisterPageTemplate("fixture-sys", templates.PageTemplateMeta{
Key: "fixture-page", Title: "Fixture Page", Slots: []string{"main"},
}, func(ctx context.Context, doc map[string]any) templates.HTMLComponent {
return textComponent("<page/>")
})
},
AdminPages: func() []plugin.AdminPage {
return []plugin.AdminPage{{Key: "fixture", Title: "Fixture Admin", Icon: "puzzle", Route: "/admin/fixture"}}
},
AIActions: func() []plugin.AIAction {
return []plugin.AIAction{{Key: "fixture.summarize", Title: "Summarize", DefaultProvider: "openai", DefaultModel: "gpt-4o"}}
},
SettingsSchema: func() []byte { return []byte(`{"type":"object"}`) },
JobHandlers: func(deps plugin.CoreServices) map[string]plugin.JobHandlerFunc {
return map[string]plugin.JobHandlerFunc{
"fixture_job": func(ctx context.Context, config json.RawMessage, progress func(int, int, string)) (json.RawMessage, error) {
return json.RawMessage(`{"ok":true}`), nil
},
}
},
Load: func(deps plugin.CoreServices) error { return nil },
Unload: func(ctx context.Context) error { return nil },
RequiredIconPacks: []string{"tabler"},
}
}
// --- helpers ---
func invokeHook(t *testing.T, g *guest, hook abiv1.Hook, payload proto.Message) *abiv1.InvokeResponse {
t.Helper()
var payloadBytes []byte
if payload != nil {
b, err := proto.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
payloadBytes = b
}
req, err := proto.Marshal(&abiv1.InvokeRequest{Hook: hook, Payload: payloadBytes})
if err != nil {
t.Fatalf("marshal envelope: %v", err)
}
out := g.invoke(uint32(hook), req)
resp := &abiv1.InvokeResponse{}
if err := proto.Unmarshal(out, resp); err != nil {
t.Fatalf("unmarshal InvokeResponse: %v", err)
}
return resp
}
// --- tests ---
func TestServeInstallsRuntime(t *testing.T) {
t.Cleanup(func() { current = nil })
Serve(fixtureRegistration())
if current == nil {
t.Fatal("Serve did not install the guest runtime")
}
resp := invokeHook(t, current, abiv1.Hook_HOOK_DESCRIBE, &abiv1.DescribeRequest{HostAbiVersion: 1})
if resp.GetError() != nil {
t.Fatalf("describe via Serve-installed runtime failed: %v", resp.GetError())
}
}
func TestDescribeManifest(t *testing.T) {
g := newGuest(fixtureRegistration())
resp := invokeHook(t, g, abiv1.Hook_HOOK_DESCRIBE, &abiv1.DescribeRequest{HostAbiVersion: 1})
if resp.GetError() != nil {
t.Fatalf("describe returned 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 m == nil {
t.Fatal("nil manifest")
}
if m.GetAbiVersion() != 1 {
t.Errorf("abi_version = %d, want 1", m.GetAbiVersion())
}
if m.GetName() != "fixture" || m.GetVersion() != "0.1.0" {
t.Errorf("name/version = %q/%q", m.GetName(), m.GetVersion())
}
if len(m.GetDependencies()) != 1 || m.GetDependencies()[0].GetPlugin() != "other" {
t.Errorf("dependencies = %v", m.GetDependencies())
}
if len(m.GetBlocks()) != 1 || m.GetBlocks()[0].GetKey() != "fixture:hello" {
t.Fatalf("blocks = %v, want one block fixture:hello", m.GetBlocks())
}
if m.GetBlocks()[0].GetCategory() != "content" {
t.Errorf("block category = %q", m.GetBlocks()[0].GetCategory())
}
if len(m.GetBlockTemplateOverrides()) != 1 ||
m.GetBlockTemplateOverrides()[0].GetTemplateKey() != "landing" ||
m.GetBlockTemplateOverrides()[0].GetBlockKey() != "hello" ||
m.GetBlockTemplateOverrides()[0].GetSource() != "fixture" {
t.Errorf("overrides = %v", m.GetBlockTemplateOverrides())
}
if len(m.GetTemplateKeys()) != 1 || m.GetTemplateKeys()[0] != "fixture-tpl" {
t.Errorf("template_keys = %v", m.GetTemplateKeys())
}
if len(m.GetSystemTemplates()) != 1 || m.GetSystemTemplates()[0].GetKey() != "fixture-sys" {
t.Errorf("system_templates = %v", m.GetSystemTemplates())
}
if len(m.GetPageTemplates()) != 1 || m.GetPageTemplates()[0].GetSystemKey() != "fixture-sys" ||
m.GetPageTemplates()[0].GetKey() != "fixture-page" {
t.Errorf("page_templates = %v", m.GetPageTemplates())
}
if len(m.GetAdminPages()) != 1 || m.GetAdminPages()[0].GetRoute() != "/admin/fixture" {
t.Errorf("admin_pages = %v", m.GetAdminPages())
}
if len(m.GetAiActions()) != 1 || m.GetAiActions()[0].GetKey() != "fixture.summarize" {
t.Errorf("ai_actions = %v", m.GetAiActions())
}
if string(m.GetSettingsSchema()) != `{"type":"object"}` {
t.Errorf("settings_schema = %s", m.GetSettingsSchema())
}
if len(m.GetJobTypes()) != 1 || m.GetJobTypes()[0] != "fixture_job" {
t.Errorf("job_types = %v", m.GetJobTypes())
}
if !m.GetHasLoadHook() || !m.GetHasUnloadHook() {
t.Errorf("has_load_hook/has_unload_hook = %v/%v, want true/true", m.GetHasLoadHook(), m.GetHasUnloadHook())
}
if m.GetHasHttpHandler() || m.GetHasMediaHooks() || m.GetHasProvisioner() {
t.Errorf("unexpected presence flags: http=%v media=%v prov=%v",
m.GetHasHttpHandler(), m.GetHasMediaHooks(), m.GetHasProvisioner())
}
if len(m.GetRequiredIconPacks()) != 1 || m.GetRequiredIconPacks()[0] != "tabler" {
t.Errorf("required_icon_packs = %v", m.GetRequiredIconPacks())
}
}
func TestDescribeRejectsUnknownHostVersion(t *testing.T) {
g := newGuest(fixtureRegistration())
resp := invokeHook(t, g, abiv1.Hook_HOOK_DESCRIBE, &abiv1.DescribeRequest{HostAbiVersion: 99})
if resp.GetError().GetCode() != abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED {
t.Fatalf("error = %v, want UNIMPLEMENTED", resp.GetError())
}
}
func TestUnknownHook(t *testing.T) {
g := newGuest(fixtureRegistration())
req, _ := proto.Marshal(&abiv1.InvokeRequest{Hook: abiv1.Hook(99)})
out := g.invoke(99, req)
resp := &abiv1.InvokeResponse{}
if err := proto.Unmarshal(out, resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp.GetError().GetCode() != abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED {
t.Fatalf("error = %v, want UNIMPLEMENTED", resp.GetError())
}
}
func TestHookIDEnvelopeMismatch(t *testing.T) {
g := newGuest(fixtureRegistration())
req, _ := proto.Marshal(&abiv1.InvokeRequest{Hook: abiv1.Hook_HOOK_DESCRIBE})
out := g.invoke(uint32(abiv1.Hook_HOOK_RENDER_BLOCK), req)
resp := &abiv1.InvokeResponse{}
if err := proto.Unmarshal(out, 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())
}
}
func TestUndecodableEnvelope(t *testing.T) {
g := newGuest(fixtureRegistration())
out := g.invoke(uint32(abiv1.Hook_HOOK_DESCRIBE), []byte{0xff, 0xff, 0xff, 0xff})
resp := &abiv1.InvokeResponse{}
if err := proto.Unmarshal(out, 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())
}
}
func TestRenderBlock(t *testing.T) {
g := newGuest(fixtureRegistration())
resp := invokeHook(t, g, abiv1.Hook_HOOK_RENDER_BLOCK, &abiv1.RenderBlockRequest{
BlockKey: "fixture:hello",
ContentJson: []byte(`{"name":"world"}`),
RenderContext: &abiv1.RenderContext{
TemplateKey: "fixture-tpl",
Page: &abiv1.PageContext{
Id: "5f0c5f3f-3f1e-4a3a-9b6e-2f4f6a6d7e8f",
Slug: "home", Title: "Home", PostType: "page", Status: "published",
},
},
})
if resp.GetError() != nil {
t.Fatalf("render error: %v", resp.GetError())
}
rr := &abiv1.RenderBlockResponse{}
if err := proto.Unmarshal(resp.GetPayload(), rr); err != nil {
t.Fatalf("unmarshal RenderBlockResponse: %v", err)
}
want := "<p>hello world on home (tpl fixture-tpl)</p>"
if rr.GetHtml() != want {
t.Errorf("html = %q, want %q", rr.GetHtml(), want)
}
}
func TestRenderBlockTemplateOverride(t *testing.T) {
g := newGuest(fixtureRegistration())
resp := invokeHook(t, g, abiv1.Hook_HOOK_RENDER_BLOCK, &abiv1.RenderBlockRequest{
BlockKey: "hello",
ContentJson: []byte(`{}`),
RenderContext: &abiv1.RenderContext{TemplateKey: "landing"},
})
if resp.GetError() != nil {
t.Fatalf("render error: %v", resp.GetError())
}
rr := &abiv1.RenderBlockResponse{}
if err := proto.Unmarshal(resp.GetPayload(), rr); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if rr.GetHtml() != "<p>override hello</p>" {
t.Errorf("html = %q", rr.GetHtml())
}
}
func TestRenderBlockUnknownKey(t *testing.T) {
g := newGuest(fixtureRegistration())
resp := invokeHook(t, g, abiv1.Hook_HOOK_RENDER_BLOCK, &abiv1.RenderBlockRequest{
BlockKey: "nope", ContentJson: []byte(`{}`),
})
if resp.GetError().GetCode() != abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED {
t.Fatalf("error = %v, want UNIMPLEMENTED", resp.GetError())
}
}
func TestPanickingBlockReturnsAbiErrorAndStaysCallable(t *testing.T) {
g := newGuest(fixtureRegistration())
resp := invokeHook(t, g, abiv1.Hook_HOOK_RENDER_BLOCK, &abiv1.RenderBlockRequest{
BlockKey: "fixture:hello", 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(), "kaboom") {
t.Errorf("error message %q does not mention panic value", e.GetMessage())
}
// The guest must remain callable after a recovered panic.
resp2 := invokeHook(t, g, abiv1.Hook_HOOK_RENDER_BLOCK, &abiv1.RenderBlockRequest{
BlockKey: "fixture:hello", ContentJson: []byte(`{"name":"again"}`),
})
if resp2.GetError() != nil {
t.Fatalf("second render failed: %v", resp2.GetError())
}
rr := &abiv1.RenderBlockResponse{}
if err := proto.Unmarshal(resp2.GetPayload(), rr); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !strings.Contains(rr.GetHtml(), "again") {
t.Errorf("html = %q", rr.GetHtml())
}
}
func TestRenderTemplate(t *testing.T) {
g := newGuest(fixtureRegistration())
resp := invokeHook(t, g, abiv1.Hook_HOOK_RENDER_TEMPLATE, &abiv1.RenderTemplateRequest{
TemplateKey: "fixture-tpl",
DocJson: []byte(`{"title":"My Page"}`),
})
if resp.GetError() != nil {
t.Fatalf("render error: %v", resp.GetError())
}
rr := &abiv1.RenderTemplateResponse{}
if err := proto.Unmarshal(resp.GetPayload(), rr); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if string(rr.GetDocument().GetBodyHtml()) != "<html>My Page</html>" {
t.Errorf("body_html = %q", rr.GetDocument().GetBodyHtml())
}
}
func TestRenderTemplatePlainComponentBecomesBodyOnly(t *testing.T) {
g := newGuest(plugin.PluginRegistration{
Name: "tpltest",
Register: func(tr templates.TemplateRegistry, _ blocks.BlockRegistry) error {
return tr.Register("plain", func(_ context.Context, _ map[string]any) templates.HTMLComponent {
return textComponent("<h1>hi</h1>")
})
},
})
resp := invokeHook(t, g, abiv1.Hook_HOOK_RENDER_TEMPLATE, &abiv1.RenderTemplateRequest{
TemplateKey: "plain",
})
if resp.GetError() != nil {
t.Fatalf("render error: %v", resp.GetError())
}
rr := &abiv1.RenderTemplateResponse{}
if err := proto.Unmarshal(resp.GetPayload(), rr); err != nil {
t.Fatalf("unmarshal: %v", err)
}
doc := rr.GetDocument()
if doc == nil {
t.Fatal("nil document")
}
if string(doc.GetBodyHtml()) != "<h1>hi</h1>" {
t.Errorf("body_html = %q", doc.GetBodyHtml())
}
if doc.GetBodyClass() != "" || doc.GetLang() != "" || len(doc.GetHtmlAttrs()) != 0 ||
len(doc.GetHeadExtraHtml()) != 0 || len(doc.GetBodyEndExtraHtml()) != 0 {
t.Errorf("plain component should set only body_html, got %+v", doc)
}
}
func TestRenderTemplatePageDocumentFillsEnvelope(t *testing.T) {
g := newGuest(plugin.PluginRegistration{
Name: "tpltest",
Register: func(tr templates.TemplateRegistry, _ blocks.BlockRegistry) error {
return tr.Register("page", func(_ context.Context, _ map[string]any) templates.HTMLComponent {
return &templates.PageDocument{
Body: textComponent("<main>body</main>"),
BodyClass: "cls",
Lang: "fr",
HTMLAttrs: map[string]string{"data-theme": "x"},
HeadExtra: textComponent(`<meta name="e">`),
BodyEndExtra: textComponent("<script>1</script>"),
}
})
},
})
resp := invokeHook(t, g, abiv1.Hook_HOOK_RENDER_TEMPLATE, &abiv1.RenderTemplateRequest{
TemplateKey: "page",
})
if resp.GetError() != nil {
t.Fatalf("render error: %v", resp.GetError())
}
rr := &abiv1.RenderTemplateResponse{}
if err := proto.Unmarshal(resp.GetPayload(), rr); err != nil {
t.Fatalf("unmarshal: %v", err)
}
doc := rr.GetDocument()
if doc == nil {
t.Fatal("nil document")
}
if string(doc.GetBodyHtml()) != "<main>body</main>" {
t.Errorf("body_html = %q", doc.GetBodyHtml())
}
if doc.GetBodyClass() != "cls" {
t.Errorf("body_class = %q", doc.GetBodyClass())
}
if doc.GetLang() != "fr" {
t.Errorf("lang = %q", doc.GetLang())
}
if doc.GetHtmlAttrs()["data-theme"] != "x" {
t.Errorf("html_attrs = %v", doc.GetHtmlAttrs())
}
if string(doc.GetHeadExtraHtml()) != `<meta name="e">` {
t.Errorf("head_extra_html = %q", doc.GetHeadExtraHtml())
}
if string(doc.GetBodyEndExtraHtml()) != "<script>1</script>" {
t.Errorf("body_end_extra_html = %q", doc.GetBodyEndExtraHtml())
}
}
func TestJobHook(t *testing.T) {
g := newGuest(fixtureRegistration())
resp := invokeHook(t, g, abiv1.Hook_HOOK_JOB, &abiv1.JobRequest{
JobType: "fixture_job", ConfigJson: []byte(`{}`),
})
if resp.GetError() != nil {
t.Fatalf("job error: %v", resp.GetError())
}
jr := &abiv1.JobResponse{}
if err := proto.Unmarshal(resp.GetPayload(), jr); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if string(jr.GetResultJson()) != `{"ok":true}` {
t.Errorf("result = %s", jr.GetResultJson())
}
}
func TestLoadUnloadHooks(t *testing.T) {
g := newGuest(fixtureRegistration())
resp := invokeHook(t, g, abiv1.Hook_HOOK_LOAD, &abiv1.LoadRequest{
HostConfig: &abiv1.HostConfig{AppUrl: "https://example.test", MediaPath: "/media"},
})
if resp.GetError() != nil {
t.Fatalf("load error: %v", resp.GetError())
}
resp = invokeHook(t, g, abiv1.Hook_HOOK_UNLOAD, &abiv1.UnloadRequest{})
if resp.GetError() != nil {
t.Fatalf("unload error: %v", resp.GetError())
}
}