返回 DeepSeek-Reasonix
plugin_resources_test.go
根目录 / internal / extension / plugin_resources_test.go
1 package extension
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "reasonix/internal/pluginpkg"
11 )
12
13 // writePluginFile writes one file inside a plugin package fixture.
14 func writePluginFile(t *testing.T, path, body string) {
15 t.Helper()
16 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
17 t.Fatal(err)
18 }
19 if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
20 t.Fatal(err)
21 }
22 }
23
24 // parseResourcesPlugin builds a v1 package with prompts and themes on disk.
25 func parseResourcesPlugin(t *testing.T) pluginpkg.Package {
26 t.Helper()
27 root := t.TempDir()
28 writePluginFile(t, filepath.Join(root, pluginpkg.NativeManifest), `{
29 "apiVersion": "reasonix.io/plugin/v1",
30 "name": "res",
31 "contributes": {
32 "prompts": ["prompts"],
33 "themes": ["themes/*.reasonix-theme"]
34 }
35 }`)
36 writePluginFile(t, filepath.Join(root, "prompts", "plan.md"), "---\ndescription: plan\n---\nPlan $ARGUMENTS")
37 writePluginFile(t, filepath.Join(root, "themes", "neon.reasonix-theme"), "theme bytes")
38 pkg, _, err := pluginpkg.ParseDir(root)
39 if err != nil {
40 t.Fatalf("ParseDir: %v", err)
41 }
42 return pkg
43 }
44
45 func TestPluginResourcesContributor(t *testing.T) {
46 pkg := parseResourcesPlugin(t)
47 contributions, err := PluginResourcesContributor(pkg).Contribute(context.Background())
48 if err != nil {
49 t.Fatal(err)
50 }
51 if len(contributions) != 2 {
52 t.Fatalf("contributions = %+v, want one prompt and one theme", contributions)
53 }
54 byKind := map[ContributionKind]Contribution{}
55 for _, c := range contributions {
56 byKind[c.Kind] = c
57 if c.Source.Scope != ScopePlugin || c.Source.PluginID != "res" {
58 t.Fatalf("contribution source = %+v, want plugin tier owned by res", c.Source)
59 }
60 }
61 prompt, ok := byKind[KindPrompt]
62 if !ok {
63 t.Fatalf("no KindPrompt contribution: %+v", contributions)
64 }
65 if prompt.ID != "plugin:res:plan" {
66 t.Fatalf("prompt ID = %q, want plugin:res:plan", prompt.ID)
67 }
68 if _, ok := prompt.Payload.(pluginpkg.PromptRef); !ok {
69 t.Fatalf("prompt payload = %T, want pluginpkg.PromptRef", prompt.Payload)
70 }
71 theme, ok := byKind[KindTheme]
72 if !ok {
73 t.Fatalf("no KindTheme contribution: %+v", contributions)
74 }
75 if theme.ID != "plugin:res:neon" {
76 t.Fatalf("theme ID = %q, want plugin:res:neon", theme.ID)
77 }
78 if _, ok := theme.Payload.(pluginpkg.ThemeRef); !ok {
79 t.Fatalf("theme payload = %T, want pluginpkg.ThemeRef", theme.Payload)
80 }
81 }
82
83 // TestManifestV1ValidatorListsStayInSync pins the validation lists pluginpkg
84 // duplicates (interceptor points, replacement slots, priority bounds)
85 // against this package's source of truth. pluginpkg cannot import extension
86 // (extension -> hook -> pluginpkg would become an import cycle), so the
87 // lists live in two places; this test makes a one-sided edit fail. It builds
88 // a manifest whose runtime enumerates every declared value and requires the
89 // pluginpkg parser to accept all of them.
90 func TestManifestV1ValidatorListsStayInSync(t *testing.T) {
91 points := []string{
92 string(PointSessionStart), string(PointSessionEnd), string(PointSessionLoad),
93 string(PointSessionSave), string(PointSessionRotate), string(PointInputReceive),
94 string(PointAgentBeforeStart), string(PointSystemPromptBuild),
95 string(PointContextPrepare), string(PointProviderRequest),
96 string(PointProviderResponse), string(PointToolBefore), string(PointToolAfter),
97 string(PointPermissionDecision), string(PointCompactionPrepare),
98 string(PointCompactionComplete), string(PointFrontendEvent),
99 }
100 slots := []string{
101 string(SlotSystemPrompt), string(SlotContext), string(SlotProviderRequest),
102 string(SlotProviderResponse), string(SlotCompaction), string(SlotSessionPolicy),
103 string(SlotPermission), string(SlotFrontendEvents),
104 string(SlotTool("bash")), string(SlotProviderRef("openai/gpt-5")),
105 }
106 for _, p := range points {
107 if !knownInterceptorPoint(InterceptorPoint(p)) {
108 t.Fatalf("test bug: %q is not a declared point", p)
109 }
110 }
111 for _, s := range slots {
112 if _, err := ParseSlot(s); err != nil {
113 t.Fatalf("test bug: slot %q does not parse: %v", s, err)
114 }
115 }
116 quoted := func(values []string) string {
117 out := make([]string, len(values))
118 for i, v := range values {
119 out[i] = `"` + v + `"`
120 }
121 return strings.Join(out, ", ")
122 }
123 root := t.TempDir()
124 manifest := `{
125 "apiVersion": "reasonix.io/plugin/v1",
126 "name": "sync-check",
127 "runtime": {
128 "command": "sync-runtime",
129 "priority": 1000,
130 "intercepts": [` + quoted(points) + `],
131 "replaces": [` + quoted(slots) + `]
132 }
133 }`
134 writePluginFile(t, filepath.Join(root, pluginpkg.NativeManifest), manifest)
135 pkg, _, err := pluginpkg.ParseDir(root)
136 if err != nil {
137 t.Fatalf("pluginpkg rejected extension-declared values (lists drifted): %v", err)
138 }
139 rt := pkg.Manifest.Runtime
140 if rt == nil || len(rt.Intercepts) != len(points) || len(rt.Replaces) != len(slots) {
141 t.Fatalf("runtime = %+v", rt)
142 }
143 if ValidatePriority(rt.Priority) != nil {
144 t.Fatalf("pluginpkg accepted priority %d that extension rejects", rt.Priority)
145 }
146 }
147
147 lines GO