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("

hello %s on %s (tpl %s)

", name, pageSlug, blocks.GetTemplateKey(ctx)) }) br.RegisterTemplateOverride("landing", "hello", func(ctx context.Context, content map[string]any) string { return "

override hello

" }) 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("" + title + "") }); 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("") }) }, 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 := "

hello world on home (tpl fixture-tpl)

" 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() != "

override hello

" { 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()) != "My Page" { 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("

hi

") }) }, }) 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()) != "

hi

" { 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("
body
"), BodyClass: "cls", Lang: "fr", HTMLAttrs: map[string]string{"data-theme": "x"}, HeadExtra: textComponent(``), BodyEndExtra: textComponent(""), } }) }, }) 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()) != "
body
" { 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()) != `` { t.Errorf("head_extra_html = %q", doc.GetHeadExtraHtml()) } if string(doc.GetBodyEndExtraHtml()) != "" { 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()) } }