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