返回 CodeWhale
marketplace_tests.rs
根目录 / crates / tui / src / commands / groups / plugins / marketplace_tests.rs
1 //! Command-surface tests for `/plugin marketplace` (#5311): add/list/show/
2 //! remove over real catalog schemas, install routed through the existing
3 //! reviewed installer, and the no-auto-anything invariants.
4
5 use super::*;
6 use crate::config::Config;
7 use crate::tui::app::{App, TuiOptions};
8 use codewhale_localization::Locale;
9 use std::fs;
10 use std::path::Path;
11 use tempfile::TempDir;
12
13 fn create_test_app(root: &Path) -> (App, TempDir) {
14 let temp = TempDir::new().expect("tempdir");
15 let tools_dir = root.join("tools");
16 fs::create_dir_all(&tools_dir).unwrap();
17 let config_path = temp.path().join("config.toml");
18 fs::write(&config_path, "[tools]\n").unwrap();
19 let options = TuiOptions {
20 config_path: Some(config_path),
21 skills_dir: temp.path().join("skills"),
22 memory_path: temp.path().join("memory.md"),
23 notes_path: temp.path().join("notes.txt"),
24 mcp_config_path: temp.path().join("mcp.json"),
25 ..crate::test_support::test_tui_options(root)
26 };
27 let config = Config::default();
28 let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
29 let registry = discovery.registry_for_workspace(root);
30 let mut app = App::new_with_plugin_registry(options, &config, registry);
31 app.ui_locale = Locale::En;
32 (app, temp)
33 }
34
35 /// A real-schema Kimi catalog: two entries, one local (installable via the
36 /// reviewed installer once a bundle exists) and one zip URL (honestly
37 /// unsupported until fetch support exists).
38 fn write_kimi_catalog(dir: &Path) -> std::path::PathBuf {
39 let catalog = serde_json::json!({
40 "version": "2",
41 "plugins": [
42 {
43 "id": "demo-bundle",
44 "source": "./demo-bundle",
45 "tier": "official",
46 "displayName": "Demo Bundle",
47 "version": "1.2.3",
48 "description": "Local demo bundle",
49 "homepage": "https://example.invalid/demo",
50 "keywords": ["demo"]
51 },
52 {
53 "id": "remote-thing",
54 "source": "https://example.invalid/remote-thing.zip",
55 "tier": "curated",
56 "displayName": "Remote Thing"
57 }
58 ]
59 });
60 let path = dir.join("kimi-marketplace.json");
61 fs::write(&path, serde_json::to_string_pretty(&catalog).unwrap()).unwrap();
62 path
63 }
64
65 fn write_demo_bundle(dir: &Path) {
66 let bundle = dir.join("demo-bundle");
67 fs::create_dir_all(bundle.join("skills/hello")).unwrap();
68 fs::write(
69 bundle.join("plugin.toml"),
70 "schema_version = 1\n[plugin]\nname = \"demo-bundle\"\nversion = \"1.0.0\"\ndescription = \"Demo\"\n[skills]\npath = \"skills\"\n",
71 )
72 .unwrap();
73 fs::write(
74 bundle.join("skills/hello/SKILL.md"),
75 "---\nname: hello\ndescription: hello\n---\nbody\n",
76 )
77 .unwrap();
78 }
79
80 fn marketplace_state_path(codewhale_home: &Path) -> std::path::PathBuf {
81 codewhale_home.join("plugins/marketplaces.json")
82 }
83
84 #[test]
85 fn marketplace_builtin_candidate_routes_to_existing_bundle_review() {
86 let _lock = crate::test_support::lock_test_env();
87 let root = TempDir::new().unwrap();
88 let codewhale_home = root.path().join("home");
89 fs::create_dir_all(&codewhale_home).unwrap();
90 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
91 let (mut app, _temp) = create_test_app(root.path());
92 let original = app
93 .plugin_registry
94 .get("computer-use")
95 .expect("shipped bundle")
96 .clone();
97 let result =
98 plugins_with_kimi_home_override(&mut app, Some("marketplace show codewhale"), None);
99 assert!(!result.is_error);
100 let text = result.message.unwrap();
101 assert!(
102 !text.contains(r"/plugin marketplace install codewhale computer\-use"),
103 "{text}"
104 );
105 assert!(
106 text.contains(&format!(
107 "/plugin show {}",
108 escape_review_text(original.id.as_str())
109 )),
110 "{text}"
111 );
112 assert!(
113 text.contains("/plugin marketplace install codewhale whalewiki"),
114 "{text}"
115 );
116 let result = plugins_with_kimi_home_override(
117 &mut app,
118 Some("marketplace install codewhale computer-use"),
119 None,
120 );
121 // B5: the published install command succeeds and says it is built in.
122 assert!(!result.is_error, "{:?}", result.message);
123 let message = result.message.unwrap();
124 assert!(message.contains("built into Codewhale"), "{message}");
125 assert!(
126 message.contains(&format!(
127 "/plugin show {}",
128 escape_review_text(original.id.as_str())
129 )),
130 "{message}"
131 );
132 let retained = app.plugin_registry.get("computer-use").unwrap();
133 assert_eq!(retained.content_hash, original.content_hash);
134 assert_eq!(retained.trust_status, original.trust_status);
135 assert_eq!(retained.enabled, original.enabled);
136 assert!(!codewhale_home.join("plugins/computer-use").exists());
137 }
138
139 #[test]
140 fn marketplace_add_list_show_remove_roundtrip() {
141 let _lock = crate::test_support::lock_test_env();
142 let root = TempDir::new().unwrap();
143 let codewhale_home = root.path().join("home");
144 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
145 let (mut app, _temp) = create_test_app(root.path());
146 let catalogs = root.path().join("catalogs");
147 fs::create_dir_all(&catalogs).unwrap();
148 let catalog_path = write_kimi_catalog(&catalogs);
149
150 // Usage errors are honest before anything is touched.
151 assert!(!plugins_with_kimi_home_override(&mut app, Some("marketplace"), None).is_error); // list, empty
152 assert!(plugins_with_kimi_home_override(&mut app, Some("marketplace add"), None).is_error);
153 assert!(
154 plugins_with_kimi_home_override(&mut app, Some("marketplace add 'bad name' x"), None)
155 .is_error
156 );
157
158 let added = plugins_with_kimi_home_override(
159 &mut app,
160 Some(&format!("marketplace add kimi {}", catalog_path.display())),
161 None,
162 );
163 assert!(!added.is_error, "{:?}", added.message);
164 let message = added.message.unwrap();
165 assert!(message.contains("Added marketplace `kimi`"), "{message}");
166 assert!(message.contains("2 candidate(s)"), "{message}");
167 assert!(message.contains("display-only"), "{message}");
168 assert!(marketplace_state_path(&codewhale_home).exists());
169
170 let list = plugins_with_kimi_home_override(&mut app, Some("marketplace list"), None)
171 .message
172 .unwrap();
173 eprintln!("LIST2 >>>{list}<<<");
174 assert!(list.contains("`kimi`"), "{list}");
175 assert!(list.contains(r"demo\-bundle"), "{list}");
176 assert!(list.contains(r"remote\-thing"), "{list}");
177 assert!(list.contains("tier=official"), "{list}");
178 assert!(list.contains("tier=curated"), "{list}");
179
180 // Stored plans keep stable codes; rendering resolves the current locale.
181 app.ui_locale = Locale::Es419;
182 let localized = plugins_with_kimi_home_override(&mut app, Some("marketplace list"), None)
183 .message
184 .unwrap();
185 assert!(localized.contains("no admite paquetes ZIP"), "{localized}");
186 assert!(!localized.contains("kimi_zip_unsupported"), "{localized}");
187 assert!(
188 !localized.contains("ZIP bundles are not supported"),
189 "{localized}"
190 );
191 app.ui_locale = Locale::En;
192
193 let show = plugins_with_kimi_home_override(&mut app, Some("marketplace show kimi"), None)
194 .message
195 .unwrap();
196 assert!(show.contains("Demo Bundle"), "{show}");
197 assert!(show.contains(r"v1\.2\.3"), "{show}");
198 assert!(show.contains("catalogs"), "{show}");
199
200 // read-only verbs never rewrite the store
201 let before = fs::read_to_string(marketplace_state_path(&codewhale_home)).unwrap();
202 plugins_with_kimi_home_override(&mut app, Some("marketplace list"), None);
203 plugins_with_kimi_home_override(&mut app, Some("marketplace show kimi"), None);
204 let after = fs::read_to_string(marketplace_state_path(&codewhale_home)).unwrap();
205 assert_eq!(
206 before, after,
207 "list/show must not rewrite marketplace state"
208 );
209
210 // Re-adding the same source is a refresh, not a duplicate: a catalog is
211 // keyed by its document, so the second add updates it in place. (The old
212 // refusal here is what produced a second snapshot of one marketplace
213 // under a hand-made name.)
214 let refreshed = plugins_with_kimi_home_override(
215 &mut app,
216 Some(&format!("marketplace add kimi {}", catalog_path.display())),
217 None,
218 );
219 assert!(!refreshed.is_error, "{:?}", refreshed.message);
220 // A *different* source under the same name is still refused.
221 let other_catalog = catalogs.join("other-marketplace.json");
222 fs::write(
223 &other_catalog,
224 serde_json::to_string_pretty(&serde_json::json!({
225 "version": "2",
226 "plugins": [
227 {
228 "id": "other-bundle",
229 "source": "./other-bundle",
230 "displayName": "Other Bundle"
231 }
232 ]
233 }))
234 .unwrap(),
235 )
236 .unwrap();
237 let clash = plugins_with_kimi_home_override(
238 &mut app,
239 Some(&format!("marketplace add kimi {}", other_catalog.display())),
240 None,
241 );
242 assert!(clash.is_error, "{:?}", clash.message);
243
244 let removed = plugins_with_kimi_home_override(&mut app, Some("marketplace remove kimi"), None);
245 assert!(!removed.is_error, "{:?}", removed.message);
246 assert!(
247 removed
248 .message
249 .unwrap()
250 .contains("Installed plugins and their trust state are unaffected")
251 );
252 assert!(
253 plugins_with_kimi_home_override(&mut app, Some("marketplace show kimi"), None).is_error
254 );
255 let empty = plugins_with_kimi_home_override(&mut app, Some("marketplace list"), None)
256 .message
257 .unwrap();
258 assert!(
259 empty.contains("codewhale") && empty.contains("whalewiki"),
260 "{empty}"
261 );
262 }
263
264 #[test]
265 fn plugin_suggest_preserves_current_main_marketplace_candidates() {
266 let _lock = crate::test_support::lock_test_env();
267 let root = TempDir::new().unwrap();
268 let codewhale_home = root.path().join("home");
269 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
270 let (mut app, _temp) = create_test_app(root.path());
271 let catalogs = root.path().join("catalogs");
272 fs::create_dir_all(&catalogs).unwrap();
273 let catalog_path = write_kimi_catalog(&catalogs);
274
275 let added = plugins_with_kimi_home_override(
276 &mut app,
277 Some(&format!("marketplace add kimi {}", catalog_path.display())),
278 None,
279 );
280 assert!(!added.is_error, "{:?}", added.message);
281
282 let suggested = plugins_with_kimi_home_override(&mut app, Some("suggest demo"), None);
283 assert!(!suggested.is_error, "{suggested:?}");
284 let message = suggested.message.expect("suggestion message");
285 assert!(message.contains("Suggested plugins"), "{message}");
286 assert!(
287 message.contains(r"demo\-bundle — not installed"),
288 "{message}"
289 );
290 assert!(
291 message.contains("/plugin marketplace install kimi demo-bundle"),
292 "{message}"
293 );
294 assert!(message.contains("Nothing was installed, trusted, or enabled."));
295 assert!(app.plugin_registry.get("demo-bundle").is_none());
296 }
297
298 #[test]
299 fn marketplace_add_rejects_symlinks_and_bad_documents() {
300 let _lock = crate::test_support::lock_test_env();
301 let root = TempDir::new().unwrap();
302 let codewhale_home = root.path().join("home");
303 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
304 let (mut app, _temp) = create_test_app(root.path());
305 let catalogs = root.path().join("catalogs");
306 fs::create_dir_all(&catalogs).unwrap();
307 let catalog_path = write_kimi_catalog(&catalogs);
308
309 // missing file
310 let missing = plugins_with_kimi_home_override(
311 &mut app,
312 Some("marketplace add nope /nonexistent/x.json"),
313 None,
314 );
315 assert!(missing.is_error);
316
317 // symlink to a real catalog is refused, not followed
318 let link = catalogs.join("link.json");
319 #[cfg(unix)]
320 std::os::unix::fs::symlink(&catalog_path, &link).unwrap();
321 #[cfg(not(unix))]
322 fs::copy(&catalog_path, &link).unwrap();
323 #[cfg(unix)]
324 {
325 let symlinked = plugins_with_kimi_home_override(
326 &mut app,
327 Some(&format!("marketplace add evil {}", link.display())),
328 None,
329 );
330 assert!(symlinked.is_error);
331 assert!(symlinked.message.unwrap().contains("symlink"));
332 }
333
334 // a document with no documented markers fails honestly and is not stored
335 let junk = catalogs.join("junk.json");
336 fs::write(&junk, "{\"hello\": \"world\"}").unwrap();
337 let bad = plugins_with_kimi_home_override(
338 &mut app,
339 Some(&format!("marketplace add junk {}", junk.display())),
340 None,
341 );
342 assert!(bad.is_error);
343 assert!(bad.message.unwrap().contains("could not be parsed"));
344 assert!(
345 plugins_with_kimi_home_override(&mut app, Some("marketplace list"), None)
346 .message
347 .unwrap()
348 .contains("codewhale")
349 );
350
351 // corrupt stored state fails closed and is never rewritten
352 let store_path = marketplace_state_path(&codewhale_home);
353 fs::create_dir_all(store_path.parent().unwrap()).unwrap();
354 fs::write(&store_path, "{ not json").unwrap();
355 let corrupt = plugins_with_kimi_home_override(&mut app, Some("marketplace list"), None);
356 assert!(corrupt.is_error);
357 assert!(corrupt.message.unwrap().contains("fail-closed"));
358 assert_eq!(fs::read_to_string(&store_path).unwrap(), "{ not json");
359 }
360
361 #[test]
362 fn marketplace_install_routes_through_reviewed_installer() {
363 let _lock = crate::test_support::lock_test_env();
364 let root = TempDir::new().unwrap();
365 let codewhale_home = root.path().join("home");
366 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
367 let (mut app, _temp) = create_test_app(root.path());
368 let catalogs = root.path().join("catalogs");
369 fs::create_dir_all(&catalogs).unwrap();
370 write_kimi_catalog(&catalogs);
371 write_demo_bundle(&catalogs);
372 assert!(
373 !plugins_with_kimi_home_override(
374 &mut app,
375 Some("marketplace add kimi catalogs/kimi-marketplace.json"),
376 None
377 )
378 .is_error
379 );
380
381 // The unsupported plan is refused before any runtime or network work.
382 let remote = plugins_with_kimi_home_override(
383 &mut app,
384 Some("marketplace install kimi remote-thing"),
385 None,
386 );
387 assert!(remote.is_error);
388 assert!(remote.message.unwrap().contains("cannot be installed"));
389
390 let runtime = tokio::runtime::Builder::new_multi_thread()
391 .worker_threads(2)
392 .enable_all()
393 .build()
394 .unwrap();
395 runtime.block_on(async {
396 let installed = plugins_with_kimi_home_override(
397 &mut app,
398 Some("marketplace install kimi demo-bundle"),
399 None,
400 );
401 assert!(!installed.is_error, "{:?}", installed.message);
402 let message = installed.message.unwrap();
403 assert!(message.contains("disabled and untrusted"), "{message}");
404 assert!(
405 message
406 .lines()
407 .any(|line| line.starts_with("/plugin trust demo-bundle ")),
408 "marketplace install must route into the trust review:\n{message}"
409 );
410
411 // No auto-trust, no auto-enable, no inherited vendor trust.
412 let plugin = app.plugin_registry.get("demo-bundle").unwrap();
413 assert!(!plugin.enabled);
414 assert!(!plugin.trusted());
415 assert!(
416 codewhale_home
417 .join("plugins/demo-bundle/.installed-from")
418 .exists()
419 );
420 });
421 }
422
423 /// A Codex catalog whose entry declares `INSTALLED_BY_DEFAULT`: the policy is
424 /// visible but nothing is auto-installed, and the npm source stays honestly
425 /// unsupported.
426 #[test]
427 fn marketplace_codex_installed_by_default_never_auto_installs() {
428 let _lock = crate::test_support::lock_test_env();
429 let root = TempDir::new().unwrap();
430 let codewhale_home = root.path().join("home");
431 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
432 let (mut app, _temp) = create_test_app(root.path());
433 let catalogs = root.path().join("catalogs");
434 fs::create_dir_all(&catalogs).unwrap();
435 let codex = serde_json::json!({
436 "name": "codex-catalog",
437 "plugins": [
438 {
439 "name": "defaulted-thing",
440 "source": { "source": "npm", "package": "@scope/defaulted-thing" },
441 "policy": { "installation": "INSTALLED_BY_DEFAULT" }
442 }
443 ]
444 });
445 let path = catalogs.join("codex-marketplace.json");
446 fs::write(&path, serde_json::to_string_pretty(&codex).unwrap()).unwrap();
447
448 let added = plugins_with_kimi_home_override(
449 &mut app,
450 Some(&format!("marketplace add codex {}", path.display())),
451 None,
452 );
453 assert!(!added.is_error, "{:?}", added.message);
454
455 let list = plugins_with_kimi_home_override(&mut app, Some("marketplace list"), None)
456 .message
457 .unwrap();
458 assert!(list.contains(r"defaulted\-thing"), "{list}");
459 assert!(list.contains("not installable"), "{list}");
460 assert!(list.contains("npm"), "{list}");
461 // Foreign auto-install policy never ran anything: no bundle on disk.
462 assert!(!codewhale_home.join("plugins/defaulted-thing").exists());
463 assert!(app.plugin_registry.get("defaulted-thing").is_none());
464 }
465
465 lines RUST