| 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, ®istry) |
| 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 |