返回 DeepSeek-Reasonix
compat_legacy_test.go
根目录 / internal / pluginpkg / compat_legacy_test.go
1 package pluginpkg
2
3 import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "reflect"
8 "testing"
9 )
10
11 // Legacy-format compatibility gates for the Plugin Manifest v1 work. The v1
12 // manifest adds apiVersion/contributes/runtime, but manifests WITHOUT an
13 // apiVersion must keep parsing exactly as before (including silently
14 // ignoring unknown fields), and plugin-packages.json must round-trip without
15 // dropping or renaming a single field. These tests pin the pre-v1 behavior
16 // so the v1 parser branch cannot regress it.
17
18 // TestLegacyNativeManifestParsesUnchanged pins the pre-v1 native manifest
19 // contract: every legacy field parses, and unknown fields stay ignored.
20 func TestLegacyNativeManifestParsesUnchanged(t *testing.T) {
21 root := t.TempDir()
22 manifest := `{
23 "name": "legacy-demo",
24 "version": "0.3.1",
25 "description": "Legacy manifest without apiVersion",
26 "homepage": "https://example.invalid/demo",
27 "repository": "https://example.invalid/repo",
28 "skills": [{"path": "skills"}, {"path": "more-skills"}],
29 "commands": "commands",
30 "hooks": {
31 "PreToolUse": [
32 {"match": "Bash", "command": "./hooks/pre.sh", "args": ["--strict"], "timeout": 5000, "description": "pre hook"}
33 ],
34 "SessionStart": [
35 {"command": "echo start", "shellCommand": true, "async": true}
36 ]
37 },
38 "mcpServers": {
39 "demo-server": {
40 "type": "stdio",
41 "command": "./server",
42 "args": ["--serve"],
43 "env": {"MODE": "prod"},
44 "autoStart": false,
45 "description": "demo"
46 }
47 },
48 "futureUnknownField": {"nested": [1, 2, 3]}
49 }`
50 if err := os.WriteFile(filepath.Join(root, NativeManifest), []byte(manifest), 0o644); err != nil {
51 t.Fatal(err)
52 }
53 pkg, _, err := ParseDir(root)
54 if err != nil {
55 t.Fatalf("ParseDir legacy manifest: %v", err)
56 }
57 if pkg.ManifestKind != "reasonix" {
58 t.Fatalf("ManifestKind = %q, want reasonix", pkg.ManifestKind)
59 }
60 m := pkg.Manifest
61 if m.Name != "legacy-demo" || m.Version != "0.3.1" || m.Description == "" || m.Homepage == "" || m.Repository == "" {
62 t.Fatalf("identity fields drifted: %+v", m)
63 }
64 // cleanPathList dedupes and sorts; the sorted order is part of the
65 // legacy contract (stable discovery output).
66 if !reflect.DeepEqual(m.Skills, []string{"more-skills", "skills"}) {
67 t.Fatalf("Skills = %#v", m.Skills)
68 }
69 // The legacy native parser has no top-level "agents" key (that arrives
70 // with Manifest v1 contributes.agents); today it is silently ignored.
71 if len(m.Agents) != 0 {
72 t.Fatalf("Agents = %#v, want empty for a legacy native manifest", m.Agents)
73 }
74 if !reflect.DeepEqual(m.Commands, []string{"commands"}) {
75 t.Fatalf("Commands = %#v", m.Commands)
76 }
77 pre := m.Hooks["PreToolUse"]
78 if len(pre) != 1 || pre[0].Command != "./hooks/pre.sh" || !pre[0].ArgsSet || !reflect.DeepEqual(pre[0].Args, []string{"--strict"}) || pre[0].Timeout != 5000 {
79 t.Fatalf("PreToolUse hook drifted: %+v", pre)
80 }
81 start := m.Hooks["SessionStart"]
82 if len(start) != 1 || start[0].Command != "echo start" || !start[0].ShellCommand || !start[0].Async {
83 t.Fatalf("SessionStart hook drifted: %+v", start)
84 }
85 srv := m.MCPServers["demo-server"]
86 // Note: an explicit "autoStart": false normalizes to nil on parse — pin
87 // the status quo so the v1 branch cannot change legacy semantics.
88 if srv.Command != "./server" || srv.AutoStart != nil || srv.Env["MODE"] != "prod" || srv.Type != "stdio" || srv.Description != "demo" {
89 t.Fatalf("mcpServers drifted: %+v", srv)
90 }
91 }
92
93 // TestLegacyHookArgsPresenceRoundTrip pins the exec/shell presence-bit
94 // contract: args:[] (exec form, empty argv) must survive a marshal
95 // round-trip distinct from args being absent (shell form).
96 func TestLegacyHookArgsPresenceRoundTrip(t *testing.T) {
97 for _, tc := range []struct {
98 name string
99 json string
100 argsSet bool
101 }{
102 {"exec form empty args", `{"command":"./a.sh","args":[]}`, true},
103 {"exec form with args", `{"command":"./a.sh","args":["x"]}`, true},
104 {"shell form no args key", `{"command":"./a.sh"}`, false},
105 } {
106 t.Run(tc.name, func(t *testing.T) {
107 var h Hook
108 if err := json.Unmarshal([]byte(tc.json), &h); err != nil {
109 t.Fatalf("unmarshal: %v", err)
110 }
111 if h.ArgsSet != tc.argsSet {
112 t.Fatalf("ArgsSet = %v, want %v", h.ArgsSet, tc.argsSet)
113 }
114 out, err := json.Marshal(h)
115 if err != nil {
116 t.Fatalf("marshal: %v", err)
117 }
118 var h2 Hook
119 if err := json.Unmarshal(out, &h2); err != nil {
120 t.Fatalf("re-unmarshal: %v", err)
121 }
122 if h2.ArgsSet != h.ArgsSet {
123 t.Fatalf("ArgsSet did not round-trip: %v -> %v (%s)", h.ArgsSet, h2.ArgsSet, out)
124 }
125 })
126 }
127 }
128
129 // TestLegacyPluginStateRoundTripPreservesFields writes a pre-v1
130 // plugin-packages.json fixture, loads and re-saves it, and requires both
131 // the typed values and the raw JSON key set to survive untouched.
132 func TestLegacyPluginStateRoundTripPreservesFields(t *testing.T) {
133 home := t.TempDir()
134 t.Setenv("REASONIX_HOME", home)
135
136 fixture := `{
137 "version": 1,
138 "plugins": [
139 {
140 "name": "alpha",
141 "source": "https://github.com/example/alpha",
142 "root": "plugins/alpha",
143 "version": "1.2.0",
144 "description": "alpha plugin",
145 "manifestKind": "reasonix",
146 "enabled": true,
147 "commit": "0123456789abcdef0123456789abcdef01234567"
148 },
149 {
150 "name": "beta",
151 "source": "/opt/plugins/beta",
152 "root": "plugins/beta",
153 "version": "0.1.0",
154 "description": "",
155 "manifestKind": "claude",
156 "enabled": false,
157 "commit": ""
158 }
159 ]
160 }`
161 if err := os.WriteFile(StatePath(home), []byte(fixture), 0o644); err != nil {
162 t.Fatal(err)
163 }
164
165 st, err := LoadState(home)
166 if err != nil {
167 t.Fatalf("LoadState: %v", err)
168 }
169 if st.Version != 1 || len(st.Plugins) != 2 {
170 t.Fatalf("state drifted: %+v", st)
171 }
172 if !st.Plugins[0].Enabled || st.Plugins[1].Enabled {
173 t.Fatalf("enabled flags drifted: %+v", st.Plugins)
174 }
175 if st.Plugins[0].Commit == "" || st.Plugins[1].ManifestKind != "claude" {
176 t.Fatalf("fields drifted: %+v", st.Plugins)
177 }
178
179 if err := SaveState(home, st); err != nil {
180 t.Fatalf("SaveState: %v", err)
181 }
182 reloaded, err := LoadState(home)
183 if err != nil {
184 t.Fatalf("reload: %v", err)
185 }
186 if !reflect.DeepEqual(st, reloaded) {
187 t.Fatalf("state did not round-trip:\nfirst: %+v\nsecond: %+v", st, reloaded)
188 }
189
190 raw, err := os.ReadFile(StatePath(home))
191 if err != nil {
192 t.Fatal(err)
193 }
194 var top map[string]json.RawMessage
195 if err := json.Unmarshal(raw, &top); err != nil {
196 t.Fatalf("saved state is not an object: %v", err)
197 }
198 var plugins []map[string]json.RawMessage
199 if err := json.Unmarshal(top["plugins"], &plugins); err != nil {
200 t.Fatalf("saved plugins malformed: %v", err)
201 }
202 // Current schema contract: keys with values always survive a save;
203 // empty strings are omitted (omitempty) — this pins the status quo so a
204 // v1 change cannot silently start dropping non-empty fields.
205 for i, p := range plugins[:1] {
206 for _, k := range []string{"name", "source", "root", "version", "description", "manifestKind", "enabled", "commit"} {
207 if _, ok := p[k]; !ok {
208 t.Fatalf("saved plugin %d lost key %q: %s", i, k, raw)
209 }
210 }
211 }
212 for i, p := range plugins[1:] {
213 for _, k := range []string{"name", "source", "root", "version", "manifestKind", "enabled"} {
214 if _, ok := p[k]; !ok {
215 t.Fatalf("saved plugin %d lost key %q: %s", i+1, k, raw)
216 }
217 }
218 if _, ok := p["description"]; ok {
219 t.Fatalf("empty description should stay omitted (omitempty contract changed): %s", raw)
220 }
221 if _, ok := p["commit"]; ok {
222 t.Fatalf("empty commit should stay omitted (omitempty contract changed): %s", raw)
223 }
224 }
225 }
226
226 lines GO