454 lines
16 KiB
Go
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())
|
|
}
|
|
}
|