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

{{ title }}

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

{{ title }}

" { 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()) } }