返回 DeepSeek-Reasonix
manifest_v1_test.go
根目录 / internal / pluginpkg / manifest_v1_test.go
1 package pluginpkg
2
3 import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "reflect"
8 "strings"
9 "testing"
10 )
11
12 // jsonText marshals v for manifest fixture construction in tests.
13 func jsonText(v any) string {
14 b, err := json.Marshal(v)
15 if err != nil {
16 panic(err)
17 }
18 return string(b)
19 }
20
21 // writeV1Plugin writes a Manifest v1 plugin tree: the manifest plus the
22 // on-disk assets its paths refer to.
23 func writeV1Plugin(t *testing.T, root, manifest string) {
24 t.Helper()
25 writeTestFile(t, filepath.Join(root, NativeManifest), manifest)
26 }
27
28 // v1ExampleManifest is the canonical Manifest v1 example: contributes with
29 // every path-list kind plus a full runtime declaration.
30 const v1ExampleManifest = `{
31 "apiVersion": "reasonix.io/plugin/v1",
32 "name": "example",
33 "version": "1.0.0",
34 "description": "Example v1 plugin",
35 "contributes": {
36 "skills": ["skills"],
37 "agents": ["agents"],
38 "commands": ["commands"],
39 "prompts": ["prompts"],
40 "themes": ["themes/*.reasonix-theme"]
41 },
42 "runtime": {
43 "command": "${REASONIX_PLUGIN_ROOT}/bin/example",
44 "args": ["--serve"],
45 "env": {"MODE": "plugin"},
46 "required": true,
47 "priority": 10,
48 "intercepts": ["input.receive", "tool.before"],
49 "replaces": ["system_prompt"],
50 "capabilities": ["interceptors", "strategies", "providers", "ui"]
51 }
52 }`
53
54 // writeV1ExampleAssets materializes every path the example manifest declares.
55 func writeV1ExampleAssets(t *testing.T, root string) {
56 t.Helper()
57 writeTestFile(t, filepath.Join(root, "skills", "demo", "SKILL.md"), "---\ndescription: demo skill\n---\nDemo")
58 writeTestFile(t, filepath.Join(root, "agents", "reviewer.md"), "---\ndescription: reviews\n---\nReview")
59 writeTestFile(t, filepath.Join(root, "commands", "ship.md"), "---\ndescription: ship\n---\nShip $ARGUMENTS")
60 writeTestFile(t, filepath.Join(root, "prompts", "plan.md"), "---\ndescription: plan\n---\nPlan $ARGUMENTS")
61 writeTestFile(t, filepath.Join(root, "themes", "neon.reasonix-theme"), "theme pack bytes")
62 writeTestFile(t, filepath.Join(root, "bin", "example"), "#!/bin/sh\n")
63 }
64
65 func TestManifestV1FullParse(t *testing.T) {
66 root := t.TempDir()
67 writeV1Plugin(t, root, v1ExampleManifest)
68 writeV1ExampleAssets(t, root)
69
70 pkg, warnings, err := ParseDir(root)
71 if err != nil {
72 t.Fatalf("ParseDir v1 manifest: %v", err)
73 }
74 if len(warnings) != 0 {
75 t.Fatalf("unexpected warnings: %v", warnings)
76 }
77 if pkg.ManifestKind != "reasonix" {
78 t.Fatalf("ManifestKind = %q, want reasonix", pkg.ManifestKind)
79 }
80 m := pkg.Manifest
81 if m.Name != "example" || m.Version != "1.0.0" || m.Description != "Example v1 plugin" {
82 t.Fatalf("identity fields: %+v", m)
83 }
84 if !reflect.DeepEqual(m.Skills, []string{"skills"}) {
85 t.Fatalf("Skills = %#v", m.Skills)
86 }
87 if !reflect.DeepEqual(m.Agents, []string{"agents"}) {
88 t.Fatalf("Agents = %#v", m.Agents)
89 }
90 if !reflect.DeepEqual(m.Commands, []string{"commands"}) {
91 t.Fatalf("Commands = %#v", m.Commands)
92 }
93 if !reflect.DeepEqual(m.Prompts, []string{"prompts"}) {
94 t.Fatalf("Prompts = %#v", m.Prompts)
95 }
96 if !reflect.DeepEqual(m.Themes, []string{"themes/*.reasonix-theme"}) {
97 t.Fatalf("Themes = %#v", m.Themes)
98 }
99 rt := m.Runtime
100 if rt == nil {
101 t.Fatal("Runtime is nil, want the declared runtime spec")
102 }
103 if rt.Command != "${REASONIX_PLUGIN_ROOT}/bin/example" {
104 t.Fatalf("Runtime.Command = %q, want the unexpanded ${REASONIX_PLUGIN_ROOT} form", rt.Command)
105 }
106 if !reflect.DeepEqual(rt.Args, []string{"--serve"}) || rt.Env["MODE"] != "plugin" {
107 t.Fatalf("Runtime args/env: %+v", rt)
108 }
109 if !rt.Required || rt.Priority != 10 {
110 t.Fatalf("Runtime required/priority: %+v", rt)
111 }
112 if !reflect.DeepEqual(rt.Intercepts, []string{"input.receive", "tool.before"}) {
113 t.Fatalf("Runtime.Intercepts = %#v", rt.Intercepts)
114 }
115 if !reflect.DeepEqual(rt.Replaces, []string{"system_prompt"}) {
116 t.Fatalf("Runtime.Replaces = %#v", rt.Replaces)
117 }
118 if !reflect.DeepEqual(rt.Capabilities, []string{"interceptors", "strategies", "providers", "ui"}) {
119 t.Fatalf("Runtime.Capabilities = %#v", rt.Capabilities)
120 }
121
122 summary := pkg.CapabilitySummary()
123 if summary.Skills != 1 || summary.Commands != 1 || summary.Prompts != 1 || summary.Themes != 1 || !summary.Runtime {
124 t.Fatalf("CapabilitySummary = %+v", summary)
125 }
126 inv := pkg.Inventory()
127 if len(inv.Prompts) != 1 || inv.Prompts[0].Name != "plan" || inv.Prompts[0].Description != "plan" {
128 t.Fatalf("Inventory.Prompts = %+v", inv.Prompts)
129 }
130 if len(inv.Themes) != 1 || inv.Themes[0].Name != "neon" {
131 t.Fatalf("Inventory.Themes = %+v", inv.Themes)
132 }
133 }
134
135 func TestManifestV1RejectsUnknownFieldsWithPath(t *testing.T) {
136 base := `{
137 "apiVersion": "reasonix.io/plugin/v1",
138 "name": "strict-demo",
139 "contributes": {"skills": ["skills"]},
140 "hooks": {"PreToolUse": [{"match": "Bash", "command": "./pre.sh"}]},
141 "mcpServers": {"srv": {"type": "stdio", "command": "./srv"}}
142 }`
143 cases := []struct {
144 name string
145 mutate func(m map[string]any)
146 wantErr string
147 }{
148 {"root field", func(m map[string]any) { m["nam"] = "typo" }, `unknown field "nam"`},
149 {"contributes field", func(m map[string]any) {
150 m["contributes"].(map[string]any)["promts"] = []string{"prompts"}
151 }, `contributes: json: unknown field "promts"`},
152 {"runtime field", func(m map[string]any) {
153 m["runtime"] = map[string]any{"command": "./run", "intrcepts": []string{"input.receive"}}
154 }, `runtime: json: unknown field "intrcepts"`},
155 {"hook entry field", func(m map[string]any) {
156 m["hooks"].(map[string]any)["PreToolUse"].([]any)[0].(map[string]any)["cmd"] = "./x"
157 }, `hooks.PreToolUse[0]: json: unknown field "cmd"`},
158 {"contributes hook entry field", func(m map[string]any) {
159 m["contributes"].(map[string]any)["hooks"] = map[string]any{
160 "SessionStart": []any{map[string]any{"command": "echo hi", "shellComand": true}},
161 }
162 }, `contributes.hooks.SessionStart[0]: json: unknown field "shellComand"`},
163 {"mcp server entry field", func(m map[string]any) {
164 m["mcpServers"].(map[string]any)["srv"].(map[string]any)["autoStart"] = false
165 }, `mcpServers.srv: json: unknown field "autoStart"`},
166 {"path list object field", func(m map[string]any) {
167 m["contributes"].(map[string]any)["skills"] = []any{map[string]any{"paht": "skills"}}
168 }, `contributes.skills[0]: json: unknown field "paht"`},
169 }
170 for _, tc := range cases {
171 t.Run(tc.name, func(t *testing.T) {
172 var doc map[string]any
173 if err := json.Unmarshal([]byte(base), &doc); err != nil {
174 t.Fatal(err)
175 }
176 tc.mutate(doc)
177 root := t.TempDir()
178 writeV1Plugin(t, root, jsonText(doc))
179 writeTestFile(t, filepath.Join(root, "skills", "s", "SKILL.md"), "---\ndescription: s\n---\nS")
180 _, _, err := ParseDir(root)
181 if err == nil {
182 t.Fatalf("ParseDir succeeded, want unknown-field rejection")
183 }
184 if !strings.Contains(err.Error(), tc.wantErr) {
185 t.Fatalf("error = %q, want it to contain %q", err.Error(), tc.wantErr)
186 }
187 })
188 }
189 }
190
191 func TestManifestV1APIVersionGating(t *testing.T) {
192 cases := []struct {
193 apiVersion any
194 wantErr string
195 }{
196 {"reasonix.io/plugin/v2", `unsupported apiVersion "reasonix.io/plugin/v2" (this Reasonix supports reasonix.io/plugin/v1)`},
197 {"reasonix.io/plugin/v0", `unsupported apiVersion`},
198 {"reasonix.io/plugin/v10.2", `unsupported apiVersion`},
199 {"v1", `invalid apiVersion "v1"`},
200 {"reasonix.io/plugin/vX", `invalid apiVersion`},
201 {"foo", `invalid apiVersion "foo"`},
202 {42, `apiVersion must be a string`},
203 // A known major with a minor parses as v1: strict field rejection is
204 // what guards against fields the minor revision added.
205 {"reasonix.io/plugin/v1.1", ""},
206 {"reasonix.io/plugin/v1.0", ""},
207 }
208 for _, tc := range cases {
209 t.Run(strings.ReplaceAll(strings.TrimSpace(jsonText(tc.apiVersion)), `"`, ""), func(t *testing.T) {
210 root := t.TempDir()
211 manifest := `{"apiVersion": ` + jsonText(tc.apiVersion) + `, "name": "ver-demo"}`
212 writeV1Plugin(t, root, manifest)
213 _, _, err := ParseDir(root)
214 if tc.wantErr == "" {
215 if err != nil {
216 t.Fatalf("ParseDir apiVersion %v: %v, want success", tc.apiVersion, err)
217 }
218 return
219 }
220 if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
221 t.Fatalf("error = %v, want it to contain %q", err, tc.wantErr)
222 }
223 })
224 }
225 }
226
227 func TestManifestV1LegacyFieldsMergeWithContributes(t *testing.T) {
228 root := t.TempDir()
229 writeV1Plugin(t, root, `{
230 "apiVersion": "reasonix.io/plugin/v1",
231 "name": "merge-demo",
232 "skills": ["legacy-skills", "shared"],
233 "commands": "legacy-commands",
234 "hooks": {
235 "SessionStart": [{"command": "./start.sh", "description": "same everywhere"}]
236 },
237 "mcpServers": {
238 "same": {"type": "stdio", "command": "./srv"},
239 "legacy-only": {"url": "https://example.invalid/mcp"}
240 },
241 "contributes": {
242 "skills": ["contrib-skills", "shared"],
243 "commands": ["contrib-commands"],
244 "prompts": ["prompts"],
245 "hooks": {
246 "SessionStart": [{"command": "./start.sh", "description": "same everywhere"}],
247 "PreToolUse": [{"match": "Bash", "command": "./pre.sh"}]
248 },
249 "mcpServers": {
250 "same": {"type": "stdio", "command": "./srv"},
251 "contrib-only": {"type": "stdio", "command": "./other"}
252 }
253 }
254 }`)
255 pkg, _, err := ParseDir(root)
256 if err != nil {
257 t.Fatalf("ParseDir merged manifest: %v", err)
258 }
259 m := pkg.Manifest
260 wantSkills := []string{"contrib-skills", "legacy-skills", "shared"}
261 if !reflect.DeepEqual(m.Skills, wantSkills) {
262 t.Fatalf("Skills = %#v, want unioned/deduped %#v", m.Skills, wantSkills)
263 }
264 wantCommands := []string{"contrib-commands", "legacy-commands"}
265 if !reflect.DeepEqual(m.Commands, wantCommands) {
266 t.Fatalf("Commands = %#v, want %#v", m.Commands, wantCommands)
267 }
268 if !reflect.DeepEqual(m.Prompts, []string{"prompts"}) {
269 t.Fatalf("Prompts = %#v", m.Prompts)
270 }
271 // Identical hook entries dedupe across the two sources; distinct events union.
272 if len(m.Hooks["SessionStart"]) != 1 {
273 t.Fatalf("SessionStart hooks = %+v, want the identical entries deduped to one", m.Hooks["SessionStart"])
274 }
275 if len(m.Hooks["PreToolUse"]) != 1 {
276 t.Fatalf("PreToolUse hooks = %+v, want the contributes entry kept", m.Hooks["PreToolUse"])
277 }
278 for _, name := range []string{"same", "legacy-only", "contrib-only"} {
279 if _, ok := m.MCPServers[name]; !ok {
280 t.Fatalf("MCPServers missing %q: %+v", name, m.MCPServers)
281 }
282 }
283 }
284
285 func TestManifestV1MergeConflictsFail(t *testing.T) {
286 cases := []struct {
287 name string
288 manifest string
289 wantErr string
290 }{
291 {"hook redefined", `{
292 "apiVersion": "reasonix.io/plugin/v1",
293 "name": "conflict-demo",
294 "hooks": {"SessionStart": [{"command": "./start.sh", "timeout": 5000}]},
295 "contributes": {"hooks": {"SessionStart": [{"command": "./start.sh", "timeout": 9000}]}}
296 }`, `hook "./start.sh" (event SessionStart) is defined differently`},
297 {"mcp server redefined", `{
298 "apiVersion": "reasonix.io/plugin/v1",
299 "name": "conflict-demo",
300 "mcpServers": {"srv": {"type": "stdio", "command": "./a"}},
301 "contributes": {"mcpServers": {"srv": {"type": "stdio", "command": "./b"}}}
302 }`, `MCP server "srv" is defined differently`},
303 }
304 for _, tc := range cases {
305 t.Run(tc.name, func(t *testing.T) {
306 root := t.TempDir()
307 writeV1Plugin(t, root, tc.manifest)
308 _, _, err := ParseDir(root)
309 if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
310 t.Fatalf("error = %v, want it to contain %q", err, tc.wantErr)
311 }
312 })
313 }
314 }
315
316 func TestManifestV1PromptsAndCommandsStaySeparateSets(t *testing.T) {
317 root := t.TempDir()
318 writeV1Plugin(t, root, `{
319 "apiVersion": "reasonix.io/plugin/v1",
320 "name": "dual-demo",
321 "contributes": {
322 "commands": ["shared"],
323 "prompts": "shared"
324 }
325 }`)
326 writeTestFile(t, filepath.Join(root, "shared", "task.md"), "---\ndescription: task\n---\nDo $ARGUMENTS")
327 pkg, _, err := ParseDir(root)
328 if err != nil {
329 t.Fatalf("ParseDir: %v", err)
330 }
331 // A path listed under both contributes.prompts and contributes.commands
332 // joins both semantic sets: slash-command discovery AND kernel prompts.
333 if !reflect.DeepEqual(pkg.Manifest.Commands, []string{"shared"}) || !reflect.DeepEqual(pkg.Manifest.Prompts, []string{"shared"}) {
334 t.Fatalf("Commands/Prompts = %#v/%#v", pkg.Manifest.Commands, pkg.Manifest.Prompts)
335 }
336 summary := pkg.CapabilitySummary()
337 if summary.Commands != 1 || summary.Prompts != 1 {
338 t.Fatalf("CapabilitySummary = %+v, want 1 command and 1 prompt from the shared path", summary)
339 }
340 }
341
342 func TestManifestV1RuntimeValidation(t *testing.T) {
343 cases := []struct {
344 name string
345 runtime string
346 wantErr string
347 }{
348 {"empty command", `{}`, `runtime.command is required`},
349 {"blank command", `{"command": " "}`, `runtime.command is required`},
350 {"empty arg", `{"command": "./run", "args": ["ok", ""]}`, `runtime.args[1] must not be empty`},
351 {"empty env key", `{"command": "./run", "env": {"": "x"}}`, `runtime.env contains an empty key`},
352 {"priority too high", `{"command": "./run", "priority": 1001}`, `runtime.priority 1001 out of range [-1000, 1000]`},
353 {"priority too low", `{"command": "./run", "priority": -1001}`, `runtime.priority -1001 out of range [-1000, 1000]`},
354 {"unknown intercept", `{"command": "./run", "intercepts": ["input.recieve"]}`, `runtime.intercepts: unknown interceptor point "input.recieve"`},
355 {"unknown replace slot", `{"command": "./run", "replaces": ["systemprompt"]}`, `runtime.replaces: unknown slot "systemprompt"`},
356 {"bad tool slot", `{"command": "./run", "replaces": ["tool:"]}`, `runtime.replaces: invalid tool slot "tool:"`},
357 {"bad provider slot", `{"command": "./run", "replaces": ["provider:openai"]}`, `runtime.replaces: invalid provider slot "provider:openai"`},
358 {"unknown capability", `{"command": "./run", "capabilities": ["filesystem"]}`, `runtime.capabilities: unknown capability "filesystem" (want one of: interceptors, strategies, providers, ui)`},
359 }
360 for _, tc := range cases {
361 t.Run(tc.name, func(t *testing.T) {
362 root := t.TempDir()
363 writeV1Plugin(t, root, `{"apiVersion": "reasonix.io/plugin/v1", "name": "rt-demo", "runtime": `+tc.runtime+`}`)
364 _, _, err := ParseDir(root)
365 if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
366 t.Fatalf("error = %v, want it to contain %q", err, tc.wantErr)
367 }
368 })
369 }
370 }
371
372 func TestManifestV1RuntimeAcceptsBoundaryValues(t *testing.T) {
373 root := t.TempDir()
374 writeV1Plugin(t, root, `{
375 "apiVersion": "reasonix.io/plugin/v1",
376 "name": "rt-ok",
377 "runtime": {
378 "command": "plugin-runtime",
379 "priority": -1000,
380 "intercepts": ["session.start", "session.end", "session.load", "session.save", "session.rotate", "input.receive", "agent.before_start", "system_prompt.build", "context.prepare", "provider.request", "provider.response", "tool.before", "tool.after", "permission.decision", "compaction.prepare", "compaction.complete", "frontend.event"],
381 "replaces": ["system_prompt", "context", "provider_request", "provider_response", "compaction", "session_policy", "permission", "frontend_events", "tool:bash", "provider:openai/gpt-5"]
382 }
383 }`)
384 pkg, _, err := ParseDir(root)
385 if err != nil {
386 t.Fatalf("ParseDir boundary runtime: %v", err)
387 }
388 if pkg.Manifest.Runtime == nil || len(pkg.Manifest.Runtime.Intercepts) != 17 || len(pkg.Manifest.Runtime.Replaces) != 10 {
389 t.Fatalf("Runtime = %+v", pkg.Manifest.Runtime)
390 }
391 }
392
393 func TestManifestV1PathSafety(t *testing.T) {
394 t.Run("dot-dot escape rejected", func(t *testing.T) {
395 root := t.TempDir()
396 writeV1Plugin(t, root, `{"apiVersion": "reasonix.io/plugin/v1", "name": "esc", "contributes": {"skills": ["../outside"]}}`)
397 if _, _, err := ParseDir(root); err == nil || !strings.Contains(err.Error(), "must be relative and stay inside the plugin root") {
398 t.Fatalf("error = %v, want the traversal refusal", err)
399 }
400 })
401 for name, absolute := range map[string]string{
402 "unix": "/etc/passwd",
403 "windows drive": `C:\Windows\System32\drivers\etc\hosts`,
404 "windows UNC": `\\server\share\secret.txt`,
405 } {
406 t.Run(name+" absolute path rejected", func(t *testing.T) {
407 root := t.TempDir()
408 writeV1Plugin(t, root, jsonText(map[string]any{
409 "apiVersion": ManifestAPIVersionV1,
410 "name": "abs",
411 "contributes": map[string]any{
412 "prompts": []string{absolute},
413 },
414 }))
415 if _, _, err := ParseDir(root); err == nil || !strings.Contains(err.Error(), "must be relative and stay inside the plugin root") {
416 t.Fatalf("error = %v, want the absolute-path refusal", err)
417 }
418 })
419 }
420 t.Run("symlink escape rejected", func(t *testing.T) {
421 outside := t.TempDir()
422 writeTestFile(t, filepath.Join(outside, "evil.md"), "evil")
423 root := t.TempDir()
424 writeV1Plugin(t, root, `{"apiVersion": "reasonix.io/plugin/v1", "name": "linkesc", "contributes": {"skills": ["skills-link"]}}`)
425 if err := os.Symlink(outside, filepath.Join(root, "skills-link")); err != nil {
426 t.Skipf("symlinks unavailable: %v", err)
427 }
428 if _, _, err := ParseDir(root); err == nil || !strings.Contains(err.Error(), "escapes the plugin root through a symlink") {
429 t.Fatalf("error = %v, want the symlink escape refusal", err)
430 }
431 })
432 t.Run("in-root symlink accepted", func(t *testing.T) {
433 root := t.TempDir()
434 writeV1Plugin(t, root, `{"apiVersion": "reasonix.io/plugin/v1", "name": "linkok", "contributes": {"skills": ["skills-link"]}}`)
435 writeTestFile(t, filepath.Join(root, "real-skills", "s", "SKILL.md"), "---\ndescription: s\n---\nS")
436 if err := os.Symlink(filepath.Join(root, "real-skills"), filepath.Join(root, "skills-link")); err != nil {
437 t.Skipf("symlinks unavailable: %v", err)
438 }
439 if _, _, err := ParseDir(root); err != nil {
440 t.Fatalf("ParseDir in-root symlink: %v", err)
441 }
442 })
443 t.Run("theme symlink escape rejected", func(t *testing.T) {
444 outside := t.TempDir()
445 writeTestFile(t, filepath.Join(outside, "evil.reasonix-theme"), "evil")
446 root := t.TempDir()
447 writeV1Plugin(t, root, `{"apiVersion": "reasonix.io/plugin/v1", "name": "themesc", "contributes": {"themes": ["themes/*.reasonix-theme"]}}`)
448 if err := os.MkdirAll(filepath.Join(root, "themes"), 0o755); err != nil {
449 t.Fatal(err)
450 }
451 if err := os.Symlink(filepath.Join(outside, "evil.reasonix-theme"), filepath.Join(root, "themes", "evil.reasonix-theme")); err != nil {
452 t.Skipf("symlinks unavailable: %v", err)
453 }
454 if _, _, err := ParseDir(root); err == nil || !strings.Contains(err.Error(), "escapes the plugin root through a symlink") {
455 t.Fatalf("error = %v, want the theme symlink escape refusal", err)
456 }
457 })
458 t.Run("non-regular theme rejected", func(t *testing.T) {
459 root := t.TempDir()
460 writeV1Plugin(t, root, `{"apiVersion": "reasonix.io/plugin/v1", "name": "themedir", "contributes": {"themes": ["themes"]}}`)
461 if err := os.MkdirAll(filepath.Join(root, "themes"), 0o755); err != nil {
462 t.Fatal(err)
463 }
464 if _, _, err := ParseDir(root); err == nil || !strings.Contains(err.Error(), `theme "themes" is not a regular file`) {
465 t.Fatalf("error = %v, want the non-regular theme refusal", err)
466 }
467 })
468 t.Run("missing paths are warnings not failures", func(t *testing.T) {
469 root := t.TempDir()
470 writeV1Plugin(t, root, `{
471 "apiVersion": "reasonix.io/plugin/v1",
472 "name": "missing-demo",
473 "contributes": {
474 "skills": ["no-such-skills"],
475 "prompts": ["no-such-prompts"],
476 "themes": ["themes/*.reasonix-theme"]
477 }
478 }`)
479 pkg, warnings, err := ParseDir(root)
480 if err != nil {
481 t.Fatalf("ParseDir with missing paths: %v", err)
482 }
483 joined := strings.Join(warnings, "\n")
484 for _, want := range []string{`skills path "no-such-skills" does not exist`, `prompts path "no-such-prompts" does not exist`, `theme glob "themes/*.reasonix-theme" matched no files`} {
485 if !strings.Contains(joined, want) {
486 t.Fatalf("warnings missing %q:\n%s", want, joined)
487 }
488 }
489 if pkg.ThemeCount() != 0 {
490 t.Fatalf("ThemeCount = %d, want 0 for an unmatched glob", pkg.ThemeCount())
491 }
492 })
493 }
494
495 func TestManifestV1DescribeRendersPromptsThemesRuntime(t *testing.T) {
496 home := t.TempDir()
497 root := filepath.Join(home, "plugins", "example")
498 writeV1Plugin(t, root, v1ExampleManifest)
499 writeV1ExampleAssets(t, root)
500 if err := Upsert(home, InstalledPlugin{Name: "example", Root: "plugins/example", Version: "1.0.0", ManifestKind: "reasonix", Enabled: true}); err != nil {
501 t.Fatal(err)
502 }
503
504 show, err := InstalledShowText(home, "example")
505 if err != nil {
506 t.Fatal(err)
507 }
508 for _, want := range []string{
509 "capabilities: 1 skills, 1 commands, 1 prompts, 0 hooks, 0 MCP servers, 1 themes",
510 "runtime: FULL TRUST",
511 "command: ${REASONIX_PLUGIN_ROOT}/bin/example --serve",
512 "intercepts: input.receive, tool.before",
513 "replaces: system_prompt",
514 "capabilities: interceptors, strategies, providers, ui",
515 "bypass permissions",
516 "prompts:\n plan - plan",
517 "themes:\n neon - ",
518 } {
519 if !strings.Contains(show, want) {
520 t.Fatalf("InstalledShowText missing %q:\n%s", want, show)
521 }
522 }
523
524 list, err := InstalledListText(home)
525 if err != nil {
526 t.Fatal(err)
527 }
528 for _, want := range []string{"1 prompts", "1 themes", "FULL TRUST runtime"} {
529 if !strings.Contains(list, want) {
530 t.Fatalf("InstalledListText missing %q:\n%s", want, list)
531 }
532 }
533 }
534
535 func TestExpandRuntimeCommand(t *testing.T) {
536 root := filepath.Join(string(filepath.Separator), "plugins", "example")
537 if got := ExpandRuntimeCommand("${REASONIX_PLUGIN_ROOT}/bin/example", root); got != filepath.Join(root, "bin", "example") {
538 t.Fatalf("ExpandRuntimeCommand = %q", got)
539 }
540 if got := ExpandRuntimeCommand("plugin-runtime", root); got != "plugin-runtime" {
541 t.Fatalf("ExpandRuntimeCommand bare name = %q", got)
542 }
543 }
544
544 lines GO