返回 CodeWhale
tests.rs
根目录 / crates / tui / src / plugins / marketplace / tests.rs
1 //! Marketplace parser tests. Every fixture is a real published schema:
2 //! the Kimi fixture is `MoonshotAI/kimi-code/plugins/marketplace.json`
3 //! verbatim; the Kimi custom-marketplace, Claude, and Codex fixtures are
4 //! the documented examples from each format's official docs, verbatim.
5 //! No synthetic formats, no invented fields.
6
7 use serde_json::Value;
8
9 use super::parsers::MarketplaceDocument;
10 use super::parsers::parse_catalog;
11 use super::types::{
12 CatalogTier, MarketplaceCatalogId, MarketplaceFormat, MarketplaceInstallPlan,
13 MarketplaceSourceSpec,
14 };
15
16 /// MoonshotAI/kimi-code `plugins/marketplace.json`, verbatim.
17 const KIMI_OFFICIAL_MARKETPLACE: &str = r#"{
18 "version": "1",
19 "plugins": [
20 {
21 "id": "kimi-datasource",
22 "tier": "official",
23 "displayName": "Kimi Datasource",
24 "version": "3.3.0",
25 "description": "Official datasource workflows.",
26 "keywords": ["data", "mcp"],
27 "source": "./official/kimi-datasource"
28 },
29 {
30 "id": "kimi-webbridge",
31 "tier": "official",
32 "displayName": "Kimi WebBridge",
33 "version": "1.11.3",
34 "description": "Control your real browser from Kimi Code.",
35 "keywords": ["browser", "automation", "webbridge"],
36 "source": "./official/kimi-webbridge"
37 },
38 {
39 "id": "superpowers",
40 "tier": "curated",
41 "displayName": "Superpowers",
42 "description": "Planning, TDD, debugging, and delivery workflows for coding agents.",
43 "homepage": "https://github.com/obra/superpowers",
44 "keywords": ["skills", "planning", "tdd", "debugging", "code-review"],
45 "source": "https://github.com/obra/superpowers"
46 },
47 {
48 "id": "vercel-plugin",
49 "tier": "curated",
50 "displayName": "Vercel Plugin",
51 "description": "Comprehensive Vercel ecosystem plugin — skills, agents, and conventions for the Vercel platform.",
52 "homepage": "https://vercel.com/docs/agent-resources/vercel-plugin",
53 "keywords": ["vercel", "deployment", "nextjs", "skills", "agents"],
54 "source": "https://github.com/vercel/vercel-plugin"
55 },
56 {
57 "id": "modern-web-guidance",
58 "tier": "curated",
59 "displayName": "Modern Web Guidance",
60 "description": "Modern web platform expertise, best practices, and browser compatibility data for coding agents, from the Google Chrome team.",
61 "homepage": "https://github.com/GoogleChrome/modern-web-guidance",
62 "keywords": ["web", "css", "browser", "frontend", "skills"],
63 "source": "https://github.com/GoogleChrome/modern-web-guidance"
64 }
65 ]
66 }"#;
67
68 /// Kimi docs "Custom marketplace JSON" example, verbatim.
69 const KIMI_DOCS_CUSTOM: &str = r#"{
70 "version": "2",
71 "plugins": [
72 {
73 "id": "my-plugin",
74 "displayName": "My Plugin",
75 "source": "./my-plugin"
76 }
77 ]
78 }"#;
79
80 /// Claude plugin-marketplaces docs example, verbatim.
81 const CLAUDE_DOCS_MARKETPLACE: &str = r#"{
82 "name": "company-tools",
83 "owner": {
84 "name": "DevTools Team",
85 "email": "devtools@example.com"
86 },
87 "plugins": [
88 {
89 "name": "code-formatter",
90 "source": "./plugins/formatter",
91 "description": "Automatic code formatting on save",
92 "version": "2.1.0",
93 "author": {
94 "name": "DevTools Team"
95 }
96 },
97 {
98 "name": "deployment-tools",
99 "source": {
100 "source": "github",
101 "repo": "company/deploy-plugin"
102 },
103 "description": "Deployment automation tools"
104 }
105 ]
106 }"#;
107
108 /// Codex plugin packaging docs marketplace example, verbatim.
109 const CODEX_DOCS_MARKETPLACE: &str = r#"{
110 "name": "local-repo",
111 "plugins": [
112 {
113 "name": "my-plugin",
114 "source": {
115 "source": "local",
116 "path": "./plugins/my-plugin"
117 },
118 "policy": {
119 "installation": "AVAILABLE",
120 "authentication": "ON_INSTALL"
121 },
122 "category": "Productivity"
123 }
124 ]
125 }"#;
126
127 fn parse(id: &str, format: MarketplaceFormat, body: &str) -> super::types::MarketplaceCatalog {
128 let root: Value = serde_json::from_str(body).expect("fixture parses as JSON");
129 parse_catalog(MarketplaceDocument {
130 catalog_id: MarketplaceCatalogId::new(id),
131 format,
132 root,
133 base: None,
134 })
135 }
136
137 fn parse_auto(id: &str, body: &str) -> super::types::MarketplaceCatalog {
138 parse(id, MarketplaceFormat::Auto, body)
139 }
140
141 #[test]
142 fn native_catalog_keeps_identity_without_fetching_artwork_or_granting_trust() {
143 let manifest: Value =
144 serde_json::from_str(include_str!("../../../plugins/computer-use/plugin.json")).unwrap();
145 let icon = manifest["extensions"]["net.codewhale"]["icon"]
146 .as_str()
147 .unwrap();
148 let mut entry = serde_json::json!({"name":"computer-use","source":"github:Hmbown/codewhale-cu-plugin","display_name":"Computer Use","author":"Codewhale","icon":icon,"platforms":["macos","windows","linux"]});
149 let catalog = parse(
150 "identity",
151 MarketplaceFormat::Codewhale,
152 &serde_json::json!({"name":"identity","plugins":[entry.clone()]}).to_string(),
153 );
154 let candidate = catalog.candidate_by_name("computer-use").unwrap();
155 assert_eq!(candidate.display_name.as_deref(), Some("Computer Use"));
156 assert_eq!(candidate.author.as_deref(), Some("Codewhale"));
157 assert_eq!(candidate.icon.as_deref(), Some(icon));
158 assert_eq!(
159 candidate.when.as_ref().unwrap().os.as_ref().unwrap().len(),
160 3
161 );
162 assert_eq!(candidate.provenance.tier, CatalogTier::Community);
163 assert!(!candidate.has_errors());
164 entry["icon"] = serde_json::json!("https://publisher.example/tracker.png");
165 let rejected = parse(
166 "identity",
167 MarketplaceFormat::Codewhale,
168 &serde_json::json!({"name":"identity","plugins":[entry]}).to_string(),
169 );
170 let candidate = rejected.candidate_by_name("computer-use").unwrap();
171 assert!(candidate.icon.is_none());
172 assert!(candidate.has_errors());
173 }
174
175 #[test]
176 fn kimi_official_marketplace_parses_all_five_real_entries() {
177 let catalog = parse_auto("kimi", KIMI_OFFICIAL_MARKETPLACE);
178 assert_eq!(catalog.format, MarketplaceFormat::Kimi);
179 assert_eq!(catalog.total_candidates(), 5);
180 assert_eq!(catalog.error_count(), 0, "real catalog must parse cleanly");
181
182 let datasource = catalog.candidate_by_name("kimi-datasource").unwrap();
183 assert_eq!(datasource.provenance.tier, CatalogTier::Official);
184 assert!(datasource.install_plan.is_supported());
185 assert_eq!(
186 datasource.source,
187 MarketplaceSourceSpec::LocalPath {
188 path: "./official/kimi-datasource".into()
189 }
190 );
191
192 let superpowers = catalog.candidate_by_name("superpowers").unwrap();
193 assert_eq!(superpowers.provenance.tier, CatalogTier::Curated);
194 assert!(superpowers.install_plan.is_supported());
195 assert!(matches!(
196 &superpowers.source,
197 MarketplaceSourceSpec::GitHub { owner, repo, .. }
198 if owner == "obra" && repo == "superpowers"
199 ));
200 }
201
202 #[test]
203 fn kimi_docs_custom_marketplace_parses() {
204 let catalog = parse_auto("kimi-custom", KIMI_DOCS_CUSTOM);
205 assert_eq!(catalog.format, MarketplaceFormat::Kimi);
206 assert_eq!(catalog.version.as_deref(), Some("2"));
207 let plugin = catalog.candidate_by_name("my-plugin").unwrap();
208 assert_eq!(
209 plugin.display_name.as_deref(),
210 Some("My Plugin"),
211 "camelCase displayName is the documented field"
212 );
213 }
214
215 #[test]
216 fn kimi_relative_sources_keep_resolution_for_install_time() {
217 let catalog = parse("kimi", MarketplaceFormat::Kimi, KIMI_OFFICIAL_MARKETPLACE);
218 let datasource = catalog.candidate_by_name("kimi-datasource").unwrap();
219 match &datasource.install_plan {
220 MarketplaceInstallPlan::Supported { spec, .. } => {
221 assert_eq!(spec, "path:./official/kimi-datasource");
222 }
223 other => panic!("relative path must be a supported local plan: {other:?}"),
224 }
225 assert_eq!(catalog.base, None, "parser never invents a base");
226 }
227
228 #[test]
229 fn kimi_zip_sources_are_visible_but_not_advertised_as_installable() {
230 let body = r#"{
231 "version": "2",
232 "plugins": [
233 {"id": "zipped", "source": "https://example.invalid/zipped.zip"},
234 {"id": "tarred", "source": "https://example.invalid/tarred.tgz"}
235 ]
236 }"#;
237 let catalog = parse("kimi", MarketplaceFormat::Kimi, body);
238 let zipped = catalog.candidate_by_name("zipped").unwrap();
239 assert!(matches!(
240 zipped.source,
241 MarketplaceSourceSpec::ArchiveUrl { .. }
242 ));
243 assert!(matches!(
244 &zipped.install_plan,
245 MarketplaceInstallPlan::Unsupported { reason, .. }
246 if reason == super::parsers::kimi::KIMI_ZIP_UNSUPPORTED_REASON
247 ));
248
249 let tarred = catalog.candidate_by_name("tarred").unwrap();
250 assert!(matches!(
251 &tarred.install_plan,
252 MarketplaceInstallPlan::Supported { source_kind, .. }
253 if source_kind == super::parsers::kimi::KIMI_GZIP_TARBALL_SOURCE_KIND
254 ));
255 }
256
257 #[test]
258 fn claude_docs_marketplace_parses_both_source_forms() {
259 let catalog = parse_auto("claude", CLAUDE_DOCS_MARKETPLACE);
260 assert_eq!(catalog.format, MarketplaceFormat::Claude);
261 assert_eq!(catalog.total_candidates(), 2);
262 assert_eq!(catalog.error_count(), 0);
263
264 let formatter = catalog.candidate_by_name("code-formatter").unwrap();
265 assert!(formatter.install_plan.is_supported());
266 assert!(matches!(
267 formatter.source,
268 MarketplaceSourceSpec::LocalPath { .. }
269 ));
270 assert_eq!(formatter.version.as_deref(), Some("2.1.0"));
271 assert_eq!(formatter.author.as_deref(), Some("DevTools Team"));
272
273 let deploy = catalog.candidate_by_name("deployment-tools").unwrap();
274 assert!(matches!(
275 &deploy.source,
276 MarketplaceSourceSpec::GitHub { owner, repo, .. }
277 if owner == "company" && repo == "deploy-plugin"
278 ));
279 match &deploy.install_plan {
280 MarketplaceInstallPlan::Supported { spec, .. } => {
281 assert_eq!(spec, "github:company/deploy-plugin");
282 }
283 other => panic!("github source must map to a supported plan: {other:?}"),
284 }
285 }
286
287 #[test]
288 fn claude_source_object_matrix_matches_documented_forms() {
289 // One entry per documented `source` discriminator, from the docs
290 // source-form table. npm/command stay honestly unsupported.
291 let body = r#"{
292 "name": "matrix", "owner": {"name": "t"},
293 "plugins": [
294 {"name": "gh", "source": {"source": "github", "repo": "a/b", "ref": "v1"}},
295 {"name": "u", "source": {"source": "url", "url": "https://git.example.com/x.git"}},
296 {"name": "gs", "source": {"source": "git-subdir", "url": "https://github.com/a/sub", "path": "p"}},
297 {"name": "np", "source": {"source": "npm", "package": "pkg"}},
298 {"name": "ar", "source": {"source": "archive", "url": "https://x.example.com/p.tgz", "sha256": "abc"}},
299 {"name": "cm", "source": {"source": "command", "command": "curl x"}}
300 ]
301 }"#;
302 let catalog = parse("claude", MarketplaceFormat::Claude, body);
303 assert_eq!(catalog.total_candidates(), 6);
304
305 let gh = catalog.candidate_by_name("gh").unwrap();
306 assert!(gh.install_plan.is_supported());
307 assert!(matches!(&gh.source,
308 MarketplaceSourceSpec::GitHub { git_ref: Some(r), .. } if r == "v1"));
309 assert!(gh.diagnostics.iter().any(|d| d.code == "UNAPPLIED_PIN"));
310
311 let url = catalog.candidate_by_name("u").unwrap();
312 assert!(!url.install_plan.is_supported());
313 assert!(matches!(url.source, MarketplaceSourceSpec::GitUrl { .. }));
314
315 let gs = catalog.candidate_by_name("gs").unwrap();
316 assert!(gs.install_plan.is_supported());
317 assert!(matches!(&gs.source,
318 MarketplaceSourceSpec::GitHub { owner, repo, .. }
319 if owner == "a" && repo == "sub"));
320
321 let np = catalog.candidate_by_name("np").unwrap();
322 assert!(!np.install_plan.is_supported());
323 assert!(matches!(&np.source, MarketplaceSourceSpec::Npm { package } if package == "pkg"));
324
325 let ar = catalog.candidate_by_name("ar").unwrap();
326 assert!(ar.install_plan.is_supported());
327 assert!(matches!(&ar.source,
328 MarketplaceSourceSpec::ArchiveUrl { url, sha256: Some(s) }
329 if url == "https://x.example.com/p.tgz" && s == "abc"));
330 assert!(ar.diagnostics.iter().any(|d| d.code == "UNVERIFIED_PIN"));
331
332 let cm = catalog.candidate_by_name("cm").unwrap();
333 assert!(!cm.install_plan.is_supported());
334 assert!(matches!(cm.source, MarketplaceSourceSpec::Refused { .. }));
335 }
336
337 #[test]
338 fn claude_component_declarations_drive_compatibility() {
339 let body = r#"{
340 "name": "comp", "owner": {"name": "t"},
341 "plugins": [
342 {"name": "skills-only", "source": "./a", "skills": ["s1", "s2"]},
343 {"name": "mixed", "source": "./b", "skills": ["s1"], "commands": ["c1"], "hooks": ["h1"]},
344 {"name": "commands-only", "source": "./c", "commands": ["c1"]},
345 {"name": "nothing-declared", "source": "./d"},
346 {"name": "mcp-only", "source": "./e", "mcpServers": {"docs": {"command": "x"}}}
347 ]
348 }"#;
349 let catalog = parse("claude", MarketplaceFormat::Claude, body);
350 use crate::plugins::manifest::PluginCompatibility as C;
351 assert_eq!(
352 catalog
353 .candidate_by_name("skills-only")
354 .unwrap()
355 .compatibility,
356 Some(C::Full)
357 );
358 assert_eq!(
359 catalog.candidate_by_name("mixed").unwrap().compatibility,
360 Some(C::Partial)
361 );
362 assert_eq!(
363 catalog
364 .candidate_by_name("commands-only")
365 .unwrap()
366 .compatibility,
367 Some(C::Unsupported)
368 );
369 assert_eq!(
370 catalog
371 .candidate_by_name("nothing-declared")
372 .unwrap()
373 .compatibility,
374 None,
375 "no declaration means decided at install review, not guessed"
376 );
377 assert_eq!(
378 catalog.candidate_by_name("mcp-only").unwrap().compatibility,
379 Some(C::Full),
380 "declared MCP without transport is treated as supported-capable"
381 );
382 }
383
384 #[test]
385 fn codex_docs_marketplace_parses_with_policy_as_display_only() {
386 let catalog = parse_auto("codex", CODEX_DOCS_MARKETPLACE);
387 assert_eq!(catalog.format, MarketplaceFormat::Codex);
388 assert_eq!(catalog.total_candidates(), 1);
389
390 let plugin = catalog.candidate_by_name("my-plugin").unwrap();
391 assert!(plugin.install_plan.is_supported());
392 assert!(matches!(
393 plugin.source,
394 MarketplaceSourceSpec::LocalPath { .. }
395 ));
396 assert_eq!(plugin.categories, vec!["Productivity".to_string()]);
397 assert!(
398 !plugin
399 .diagnostics
400 .iter()
401 .any(|d| d.code == "NO_AUTO_INSTALL"),
402 "AVAILABLE policy needs no warning"
403 );
404 }
405
406 #[test]
407 fn codex_installed_by_default_never_auto_installs_and_says_so() {
408 let body = r#"{
409 "name": "n",
410 "plugins": [
411 {"name": "p", "source": {"source": "local", "path": "./p"},
412 "policy": {"installation": "INSTALLED_BY_DEFAULT"}}
413 ]
414 }"#;
415 let catalog = parse("codex", MarketplaceFormat::Codex, body);
416 let plugin = catalog.candidate_by_name("p").unwrap();
417 assert!(
418 plugin
419 .diagnostics
420 .iter()
421 .any(|d| d.code == "NO_AUTO_INSTALL"),
422 "auto-install policy must be visibly ignored on the entry itself"
423 );
424 }
425
426 #[test]
427 fn codex_not_available_downgrades_install_plan() {
428 let body = r#"{
429 "name": "n",
430 "plugins": [
431 {"name": "p", "source": {"source": "local", "path": "./p"},
432 "policy": {"installation": "NOT_AVAILABLE"}}
433 ]
434 }"#;
435 let catalog = parse("codex", MarketplaceFormat::Codex, body);
436 let plugin = catalog.candidate_by_name("p").unwrap();
437 assert!(!plugin.install_plan.is_supported());
438 }
439
440 #[test]
441 fn codewhale_native_catalog_maps_install_specs() {
442 let body = r#"{
443 "name": "team", "description": "Team plugins", "version": "1",
444 "plugins": [
445 {"name": "fmt", "source": "github:owner/repo", "version": "2.1.0"},
446 {"name": "local", "source": "path:./bundled/fmt"},
447 {"name": "bad", "source": "github:"}
448 ]
449 }"#;
450 let catalog = parse_auto("native", body);
451 assert_eq!(catalog.format, MarketplaceFormat::Codewhale);
452 assert_eq!(catalog.total_candidates(), 3);
453
454 assert!(
455 catalog
456 .candidate_by_name("fmt")
457 .unwrap()
458 .install_plan
459 .is_supported()
460 );
461 assert!(
462 catalog
463 .candidate_by_name("local")
464 .unwrap()
465 .install_plan
466 .is_supported()
467 );
468 let bad = catalog.candidate_by_name("bad").unwrap();
469 assert!(
470 !bad.install_plan.is_supported(),
471 "invalid spec stays visible and unsupported"
472 );
473 }
474
475 #[test]
476 fn malformed_entry_degrades_alone() {
477 let body = r#"{
478 "version": "1",
479 "plugins": [
480 {"id": "good", "source": "./good"},
481 "not-an-object",
482 {"displayName": "missing id and source"},
483 {"id": "also-good", "source": "https://github.com/a/b"}
484 ]
485 }"#;
486 let catalog = parse("kimi", MarketplaceFormat::Kimi, body);
487 assert_eq!(catalog.total_candidates(), 2, "survivors stay listed");
488 assert!(catalog.candidate_by_name("good").is_some());
489 assert!(catalog.candidate_by_name("also-good").is_some());
490 assert!(catalog.error_count() >= 2);
491 }
492
493 #[test]
494 fn missing_plugins_array_is_a_catalog_error() {
495 for (format, body) in [
496 (MarketplaceFormat::Kimi, r#"{"version": "1"}"#),
497 (
498 MarketplaceFormat::Claude,
499 r#"{"name": "x", "owner": {"name": "o"}}"#,
500 ),
501 (MarketplaceFormat::Codex, r#"{"name": "x"}"#),
502 (MarketplaceFormat::Codewhale, r#"{"name": "x"}"#),
503 ] {
504 let catalog = parse("t", format, body);
505 assert_eq!(catalog.total_candidates(), 0);
506 assert!(catalog.error_count() >= 1, "{format} needs a plugins array");
507 }
508 }
509
510 #[test]
511 fn provenance_tiers_never_grant_trust() {
512 let catalog = parse("kimi", MarketplaceFormat::Kimi, KIMI_OFFICIAL_MARKETPLACE);
513 for candidate in &catalog.candidates {
514 assert!(!candidate.provenance.grants_trust());
515 assert!(
516 candidate.compatibility.is_none() || !candidate.has_errors(),
517 "official/curated labels are display-only"
518 );
519 }
520 let official = catalog.candidate_by_name("kimi-datasource").unwrap();
521 assert_eq!(official.provenance.tier, CatalogTier::Official);
522 }
523
524 #[test]
525 fn unknown_fields_warn_instead_of_being_reinterpreted() {
526 let body = r#"{
527 "version": "1",
528 "plugins": [
529 {"id": "p", "source": "./p", "download_url": "https://x", "entries": []}
530 ]
531 }"#;
532 let catalog = parse("kimi", MarketplaceFormat::Kimi, body);
533 assert_eq!(catalog.total_candidates(), 1);
534 let plugin = catalog.candidate_by_name("p").unwrap();
535 assert!(
536 plugin
537 .diagnostics
538 .iter()
539 .any(|d| d.code == "UNKNOWN_FIELD" && d.message.contains("download_url")),
540 "invented fields must be visibly ignored, not parsed"
541 );
542 }
543
544 #[test]
545 fn archive_url_catalog_detection_preserves_native_and_kimi_identity() {
546 for url in [
547 "https://example.invalid/plugins.tar.gz#path=plugins/tools",
548 "http://127.0.0.1:9000/plugins.tar.gz#path=plugins/tools",
549 ] {
550 let native = serde_json::json!({"plugins": [{"name": "tools", "source": url}]});
551 let catalog = parse_auto("native", &native.to_string());
552 assert_eq!(catalog.format, MarketplaceFormat::Codewhale);
553 assert_eq!(catalog.error_count(), 0);
554 assert!(matches!(
555 &catalog.candidate_by_name("tools").unwrap().install_plan,
556 MarketplaceInstallPlan::Supported { spec, .. } if spec == url
557 ));
558
559 let kimi = serde_json::json!({"plugins": [{"id": "tools", "source": url}]});
560 assert_eq!(
561 parse_auto("kimi", &kimi.to_string()).format,
562 MarketplaceFormat::Kimi
563 );
564 }
565 }
566
567 #[test]
568 fn auto_detection_reports_ambiguity_instead_of_guessing() {
569 let body = r#"{"plugins": [{"name": "p", "source": "./p"}]}"#;
570 let catalog = parse_auto("t", body);
571 assert_eq!(catalog.total_candidates(), 0);
572 assert!(
573 catalog
574 .diagnostics
575 .iter()
576 .any(|d| d.code == "AMBIGUOUS_FORMAT")
577 );
578 }
579
580 #[test]
581 fn non_object_catalog_fails_closed() {
582 let catalog = parse_auto("t", "[1, 2, 3]");
583 assert_eq!(catalog.total_candidates(), 0);
584 assert!(catalog.error_count() >= 1);
585 }
586
587 #[test]
588 fn claude_relative_install_source_is_rooted_outside_catalog_metadata() {
589 use super::document::{
590 CatalogInstallResolution, load_catalog_document, resolve_candidate_install,
591 };
592 let tmp = tempfile::tempdir().unwrap();
593 let metadata = tmp.path().join(".claude-plugin");
594 std::fs::create_dir_all(&metadata).unwrap();
595 let path = metadata.join("marketplace.json");
596 std::fs::write(&path, r#"{"name":"official","owner":{"name":"Example"},"plugins":[{"name":"linear","source":"./external_plugins/linear"}]}"#).unwrap();
597 let loaded = load_catalog_document("official", tmp.path(), path.to_str().unwrap()).unwrap();
598 let candidate = loaded.entry.catalog.candidate_by_name("linear").unwrap();
599 let registry = crate::plugins::PluginRegistry::empty(tmp.path());
600 let CatalogInstallResolution::Supported { spec, .. } =
601 resolve_candidate_install(&loaded.entry, candidate, &registry)
602 else {
603 panic!("expected path source")
604 };
605 assert_eq!(
606 std::path::Path::new(spec.strip_prefix("path:").unwrap()),
607 tmp.path().join("external_plugins/linear")
608 );
609 }
610
610 lines RUST