Preserve plugin caches across display metadata refreshes (#46309)

## Why

Renewed image URLs and other display metadata changes unnecessarily invalidate loaded plugins and MCP and skill caches, even when installed plugin behavior is unchanged.

## What changed

- Compare installed plugin metadata by identity, version, enablement, policy, and availability before invalidating derived caches. Continue storing the full updated payload so display consumers receive fresh metadata.
- Preserve invalidation when behavioral metadata changes or reconciliation requires an effective plugin refresh.
- Export `remote_catalog_metadata_eq` to compare catalogs independently of display metadata and plugin display order, while retaining marketplace order significance.

## Testing

Add regression tests for display-only updates, behavioral changes, catalog ordering, and preservation of loaded skills and tool suggestions. Add an app-server integration test verifying that image URL renewals and badge updates preserve live MCP sessions and cached skill resources, while an authentication policy change invalidates resource caches.

GitOrigin-RevId: 22b9ba1234a9f850201c6890e03d1ef899a56c54
This commit is contained in:
vkg-oai
2026-09-17 21:27:59 +00:00
committed by copyberry
parent fa8cf44985
commit 0c9be8a836
8 changed files with 697 additions and 3 deletions

View File

@@ -117,6 +117,9 @@ use tokio::sync::Notify;
use tokio::task::JoinHandle;
use tokio::time::timeout;
#[path = "mcp_resource/plugin_metadata_refresh.rs"]
mod plugin_metadata_refresh;
pub(super) const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
const TEST_RESOURCE_URI: &str = "test://codex/resource";
pub(super) const TEST_WIDGET_RESOURCE_URI: &str = "ui://widget/checkout-session.html";

View File

@@ -0,0 +1,284 @@
//! Exercises installed-metadata refreshes against live MCP and skill caches.
use super::*;
use axum::Json;
use axum::routing::get;
use codex_app_server_protocol::PluginInstalledResponse;
use codex_app_server_protocol::PluginReconcileResponse;
use flate2::Compression;
use flate2::write::GzEncoder;
use pretty_assertions::assert_eq;
use tokio::sync::RwLock;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn signed_image_renewal_preserves_live_mcp_and_skills() -> Result<()> {
let responses_server = responses::start_mock_server().await;
let listener = TcpListener::bind("127.0.0.1:0").await?;
let base_url = format!("http://{}", listener.local_addr()?);
let original_logo = "https://files.openai.com/plugins/logo.png?sv=1&sr=b&sig=first&se=old";
let renewed_logo = "https://files.openai.com/plugins/logo.png?sv=1&sr=b&sig=second&se=new";
let changed_logo = "https://files.openai.com/plugins/new-logo.png?sv=1&sr=b&sig=third&se=new";
let installed = Arc::new(RwLock::new(json!({
"id": "plugins~Plugin_00000000000000000000000000000000",
"name": "demo-plugin",
"scope": "GLOBAL",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {
"version": "1.0.0",
"display_name": "Demo plugin",
"description": "Test plugin",
"bundle_download_url": format!("{base_url}/bundle"),
"interface": {"logo_url": original_logo},
},
"enabled": true,
})));
let manifest = br#"{"name":"demo-plugin"}"#;
let mut archive = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
let mut header = tar::Header::new_gnu();
header.set_size(manifest.len() as u64);
header.set_mode(/*mode*/ 0o644);
header.set_cksum();
archive.append_data(&mut header, ".codex-plugin/plugin.json", &manifest[..])?;
let bundle = archive.into_inner()?.finish()?;
let calls = Arc::new(ResourceAppsMcpCalls::default());
let sessions = Arc::new(AtomicUsize::new(0));
let server_calls = Arc::clone(&calls);
let server_sessions = Arc::clone(&sessions);
let mcp_service = StreamableHttpService::new(
move || {
server_sessions.fetch_add(1, Ordering::SeqCst);
Ok(MetadataMcpServer(ResourceAppsMcpServer {
calls: Arc::clone(&server_calls),
}))
},
Arc::new(LocalSessionManager::default()),
StreamableHttpServerConfig::default(),
);
let server_installed = Arc::clone(&installed);
let router = Router::new()
.nest_service("/api/codex/ps/mcp", mcp_service)
.route(
"/ps/plugins/installed",
get(move || {
let installed = Arc::clone(&server_installed);
async move {
Json(json!({
"plugins": [installed.read().await.clone()],
"pagination": {"limit": 200, "next_page_token": null},
}))
}
}),
)
.route("/bundle", get(move || async move { bundle }));
let server_handle = tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&responses_server.uri())
.with_root_config(&format!("chatgpt_base_url = \"{base_url}\""))
.enable_feature(Feature::Apps)
.enable_feature(Feature::Plugins)
.enable_feature(Feature::RemotePlugin)
.disable_feature(Feature::PluginSharing)
.with_extra_config("[skills]\ninclude_instructions = true")
.write(codex_home.path())?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_managed_config()
.with_env_overrides(&[(
"CODEX_TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS",
Some("1"),
)])
.build_initialized()
.await?;
refresh_and_expect_logo(&mut app_server, original_logo).await?;
// Hosted skills are exposed without a local executor, as in the other resource tests.
let request_id = app_server
.send_thread_start_request(ThreadStartParams {
model: Some("gpt-5.5".to_string()),
environments: Some(Vec::new()),
..Default::default()
})
.await?;
let ThreadStartResponse { thread, .. } =
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(request_id)).await??;
read_skill_in_turn(&mut app_server, &responses_server, &thread.id).await?;
let warm_sessions = sessions.load(Ordering::SeqCst);
assert!(warm_sessions > 0);
let warm_calls = calls.snapshot();
assert_eq!(warm_calls.list_resources, 1);
assert_eq!(warm_calls.main_prompt_reads, 1);
installed.write().await["release"]["interface"]["logo_url"] = json!(renewed_logo);
refresh_and_expect_logo(&mut app_server, renewed_logo).await?;
read_skill_in_turn(&mut app_server, &responses_server, &thread.id).await?;
assert_eq!(sessions.load(Ordering::SeqCst), warm_sessions);
assert_eq!(calls.snapshot(), warm_calls);
installed.write().await["release"]["interface"]["capabilities"] =
json!(["Updated store badge"]);
refresh_and_expect_logo(&mut app_server, renewed_logo).await?;
read_skill_in_turn(&mut app_server, &responses_server, &thread.id).await?;
assert_eq!(sessions.load(Ordering::SeqCst), warm_sessions);
assert_eq!(calls.snapshot(), warm_calls);
// A policy change must invalidate both caches without rewriting any bundle files.
installed.write().await["release"]["interface"]["logo_url"] = json!(changed_logo);
installed.write().await["authentication_policy"] = json!("ON_INSTALL");
refresh_and_expect_logo(&mut app_server, changed_logo).await?;
read_skill_in_turn(&mut app_server, &responses_server, &thread.id).await?;
// Core may retain the HTTP connection, but the callback must invalidate resources.
assert_eq!(
calls.snapshot(),
ResourceAppsMcpCallCounts {
list_resources: 2,
main_prompt_reads: 2,
reference_reads: 0,
}
);
server_handle.abort();
Ok(())
}
async fn refresh_and_expect_logo(app_server: &mut TestAppServer, logo: &str) -> Result<()> {
// plugin/installed starts the real background sync with the production callback.
// Reconcile acquires the same gate, so its completion fences the background pass.
let request = app_server
.send_raw_request("plugin/installed", Some(json!({})))
.await?;
let _: PluginInstalledResponse =
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(request)).await??;
let request = app_server
.send_raw_request("plugin/reconcile", Some(json!({})))
.await?;
let reconciled: PluginReconcileResponse =
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(request)).await??;
assert!(reconciled.failed_remote_plugin_ids.is_empty());
assert!(
reconciled
.failed_materialization_remote_plugin_ids
.is_empty()
);
let request = app_server
.send_raw_request("plugin/installed", Some(json!({})))
.await?;
let installed: PluginInstalledResponse =
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(request)).await??;
let plugin = installed
.marketplaces
.iter()
.flat_map(|marketplace| &marketplace.plugins)
.find(|plugin| plugin.name == "demo-plugin")
.context("installed plugin should be exposed through the public API")?;
assert_eq!(
plugin
.interface
.as_ref()
.and_then(|info| info.logo_url.as_deref()),
Some(logo)
);
// Finish the follow-up read's background pass before changing the next snapshot.
let request = app_server
.send_raw_request("plugin/reconcile", Some(json!({})))
.await?;
let _: PluginReconcileResponse =
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(request)).await??;
Ok(())
}
async fn read_skill_in_turn(
app_server: &mut TestAppServer,
responses_server: &wiremock::MockServer,
thread_id: &str,
) -> Result<()> {
let response_mock = responses::mount_sse_sequence(
responses_server,
vec![
responses::sse(vec![
responses::ev_function_call_with_namespace(
"read-main",
"skills",
"read",
&json!({"package": SKILL_RESOURCE_URI}).to_string(),
),
responses::ev_completed("read"),
]),
responses::sse(vec![responses::ev_completed("done")]),
],
)
.await;
let completed = timeout(
DEFAULT_READ_TIMEOUT,
app_server.start_turn_and_wait_for_completion(TurnStartParams {
thread_id: thread_id.to_string(),
input: vec![UserInput::Text {
text: "Read the deployment skill.".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
}),
)
.await??;
assert_eq!(completed.turn.status, TurnStatus::Completed);
let requests = response_mock.requests();
assert_eq!(requests.len(), 2);
assert!(
requests[0]
.message_input_texts("developer")
.iter()
.any(|text| text.contains(SKILL_NAME))
);
let output = requests[1]
.function_call_output_text("read-main")
.context("skill read should reach the model")?;
assert_eq!(
serde_json::from_str::<serde_json::Value>(&output)?,
json!({"resource": SKILL_MAIN_PROMPT_URI, "contents": SKILL_CONTENTS, "next_cursor": null})
);
Ok(())
}
// Reuse the resource-read fixture with a healthy, single-page skills catalog.
struct MetadataMcpServer(ResourceAppsMcpServer);
impl ServerHandler for MetadataMcpServer {
fn get_info(&self) -> ServerInfo {
self.0.get_info()
}
async fn list_resources(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListResourcesResult, rmcp::ErrorData> {
self.0.calls.list_resources.fetch_add(1, Ordering::Relaxed);
Ok(ListResourcesResult::with_all_items(vec![skill_resource(
SKILL_RESOURCE_URI,
"plugin_demo/deploy",
RAW_SKILL_DESCRIPTION,
"mcp/skill",
"demo-plugin",
"deploy",
)]))
}
async fn read_resource(
&self,
request: ReadResourceRequestParams,
context: RequestContext<RoleServer>,
) -> Result<rmcp::model::ReadResourceResponse, rmcp::ErrorData> {
self.0.read_resource(request, context).await
}
}

View File

@@ -25,6 +25,7 @@ mod recommended_plugin_install;
pub mod remote;
pub mod remote_bundle;
pub mod remote_legacy;
mod remote_metadata;
mod remote_plugin_id_resolver;
mod script_attribution;
mod skill_snapshots;
@@ -105,6 +106,7 @@ pub use provider::ResolvedExecutorPlugin;
pub use recommended_plugin_install::hydrate_selected_recommended_plugin_install_metadata;
pub use remote::RecommendedPlugin;
pub use remote::RecommendedPluginsMode;
pub use remote_metadata::remote_catalog_metadata_eq;
pub use script_attribution::PluginCommandAttribution;
pub use script_attribution::TrustedPluginRoots;
pub use script_attribution::command_script_arguments;

View File

@@ -1417,10 +1417,16 @@ impl PluginsManager {
}
return Some(needs_effective_plugins_refresh);
}
let metadata_changed = cache.plugins.as_ref().is_none_or(|previous| {
!crate::remote_metadata::installed_plugin_metadata_eq(previous, &plugins)
});
cache.plugins = Some(plugins);
drop(cache);
self.clear_loaded_plugins_cache();
Some(true)
let changed = needs_effective_plugins_refresh || metadata_changed;
if changed {
self.clear_loaded_plugins_cache();
}
Some(changed)
}
#[cfg(test)]

View File

@@ -78,6 +78,9 @@ const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024;
#[path = "marketplace_policy/curated_loading_tests.rs"]
mod curated_marketplace_policy;
#[path = "remote_metadata_cache_tests.rs"]
mod remote_metadata_cache;
fn unrestricted_config_layer_stack() -> ConfigLayerStack {
ConfigLayerStack::default()
}
@@ -7270,7 +7273,10 @@ remote_plugin = true
/*on_effective_plugins_changed*/ None,
);
tokio::time::sleep(Duration::from_millis(400)).await;
let _guard = first_manager
.acquire_remote_installed_plugin_sync_guard()
.await
.expect("background bundle sync should finish");
server.verify().await;
}

View File

@@ -0,0 +1,77 @@
//! Compares the metadata that determines installed-plugin behavior.
//!
//! Display metadata (including image URLs and store capability badges) is excluded. Callers must
//! still publish the unmodified payload so display consumers receive fresh metadata.
use crate::remote::RemoteInstalledPlugin;
use crate::remote::RemoteMarketplace;
pub(crate) fn installed_plugin_metadata_eq(
previous: &[RemoteInstalledPlugin],
current: &[RemoteInstalledPlugin],
) -> bool {
previous.len() == current.len()
&& previous.iter().zip(current).all(|(previous, current)| {
previous.marketplace_name == current.marketplace_name
&& previous.id == current.id
&& previous.name == current.name
&& previous.canonical_app_id == current.canonical_app_id
&& previous.version == current.version
&& previous.installed_at == current.installed_at
&& previous.enabled == current.enabled
&& previous.install_policy == current.install_policy
&& previous.install_policy_source == current.install_policy_source
&& previous.must_show_installation_interstitial
== current.must_show_installation_interstitial
&& previous.auth_policy == current.auth_policy
&& previous.availability == current.availability
&& previous.disabled_reason == current.disabled_reason
&& previous.eligible_plan_types == current.eligible_plan_types
})
}
/// Compare installed catalog identity, versions, enablement, policy and availability.
///
/// Plugin display order is ignored. Marketplace ordering remains significant.
/// Callers must still store the unmodified current catalog for display consumers.
pub fn remote_catalog_metadata_eq(
previous: &[RemoteMarketplace],
current: &[RemoteMarketplace],
) -> bool {
previous.len() == current.len()
&& previous.iter().zip(current).all(|(previous, current)| {
if previous.name != current.name || previous.plugins.len() != current.plugins.len() {
return false;
}
// Core sorts this view by display name, which can change independently of behavior.
let mut previous = previous.plugins.iter().collect::<Vec<_>>();
let mut current = current.plugins.iter().collect::<Vec<_>>();
previous.sort_unstable_by(|a, b| a.remote_plugin_id.cmp(&b.remote_plugin_id));
current.sort_unstable_by(|a, b| a.remote_plugin_id.cmp(&b.remote_plugin_id));
previous
.into_iter()
.zip(current)
.all(|(previous, current)| {
previous.id == current.id
&& previous.remote_plugin_id == current.remote_plugin_id
&& previous.name == current.name
&& previous.version == current.version
&& previous.local_version == current.local_version
&& previous.installed == current.installed
&& previous.installed_at == current.installed_at
&& previous.enabled == current.enabled
&& previous.install_policy == current.install_policy
&& previous.install_policy_source == current.install_policy_source
&& previous.must_show_installation_interstitial
== current.must_show_installation_interstitial
&& previous.auth_policy == current.auth_policy
&& previous.availability == current.availability
&& previous.disabled_reason == current.disabled_reason
&& previous.eligible_plan_types == current.eligible_plan_types
})
})
}
#[cfg(test)]
#[path = "remote_metadata_tests.rs"]
mod tests;

View File

@@ -0,0 +1,135 @@
//! Metadata publication must preserve derived caches until runtime inputs change.
use super::*;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn display_refresh_preserves_loaded_skills_and_tool_suggestions() {
let codex_home = TempDir::new().unwrap();
let marketplace_root = codex_home
.path()
.join("plugins/cache/openai-curated-remote");
write_plugin(&marketplace_root, "sample/local", "sample");
let plugin_root = marketplace_root.join("sample/local");
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
remote_plugin = true
[plugins."sample@openai-curated-remote"]
enabled = true
"#,
);
let config = load_config(codex_home.path(), codex_home.path()).await;
let manager = test_plugins_manager_with_options(
codex_home.path().to_path_buf(),
Some(Product::Codex),
Some(AuthMode::Chatgpt),
);
let mut remote = remote_installed_plugin("sample");
remote.interface = Some(
serde_json::from_value(serde_json::json!({
"capabilities": [], "screenshots": [], "screenshotUrls": [],
"logoUrl": "https://files.openai.com/logo.png?sig=old"
}))
.unwrap(),
);
assert!(manager.write_remote_installed_plugins_cache(vec![remote.clone()]));
let loaded = manager.plugins_for_config(&config).await;
let snapshots = manager.plugin_skill_snapshots_for_config(&config).unwrap();
let roots = loaded.effective_plugin_skill_roots();
assert_eq!(roots.len(), 1);
assert_eq!(snapshots.get(&roots[0]).unwrap().skills.len(), 1);
let loaded_generation = manager.loaded_plugins_cache_generation();
let plugin = ConfiguredMarketplacePlugin {
id: "sample@openai-curated-remote".to_string(),
name: "sample".to_string(),
local_version: None,
installed_version: None,
source: MarketplacePluginSource::Local {
path: plugin_root.abs(),
},
policy: MarketplacePluginPolicy {
installation: MarketplacePluginInstallPolicy::Available,
authentication: MarketplacePluginAuthPolicy::OnUse,
products: None,
},
interface: None,
keywords: Vec::new(),
manifest_fallback: None,
installed: true,
enabled: true,
};
let suggestions = manager
.tool_suggest_metadata_cache
.metadata_for_plugin(
REMOTE_GLOBAL_MARKETPLACE_NAME,
&plugin,
manager.restriction_product,
manager.skill_root_loader.as_ref(),
)
.await
.unwrap();
assert!(
suggestions
.project(&SkillConfigRules::default(), manager.auth_mode())
.has_skills
);
let interface = remote.interface.as_mut().unwrap();
interface.logo_url = Some("https://files.openai.com/logo.png?sig=new".to_string());
interface.display_name = Some("Updated display name".to_string());
interface.capabilities = vec!["Updated store badge".to_string()];
assert!(!manager.write_remote_installed_plugins_cache(vec![remote.clone()]));
assert_eq!(
manager
.remote_installed_plugins_cache
.read()
.unwrap()
.plugins,
Some(vec![remote.clone()])
);
assert_eq!(manager.loaded_plugins_cache_generation(), loaded_generation);
assert_eq!(
manager.plugin_skill_snapshots_for_config(&config),
Some(snapshots.clone())
);
assert_eq!(manager.plugins_for_config(&config).await, loaded);
let refreshed_suggestions = manager
.tool_suggest_metadata_cache
.metadata_for_plugin(
REMOTE_GLOBAL_MARKETPLACE_NAME,
&plugin,
manager.restriction_product,
manager.skill_root_loader.as_ref(),
)
.await
.unwrap();
assert!(Arc::ptr_eq(&suggestions, &refreshed_suggestions));
remote.version = Some("2.0.0".to_string());
assert!(manager.write_remote_installed_plugins_cache(vec![remote]));
assert_eq!(
manager.loaded_plugins_cache_generation(),
loaded_generation + 1
);
assert_eq!(manager.plugin_skill_snapshots_for_config(&config), None);
manager.plugins_for_config(&config).await;
assert_ne!(
manager.plugin_skill_snapshots_for_config(&config),
Some(snapshots)
);
let changed_suggestions = manager
.tool_suggest_metadata_cache
.metadata_for_plugin(
REMOTE_GLOBAL_MARKETPLACE_NAME,
&plugin,
manager.restriction_product,
manager.skill_root_loader.as_ref(),
)
.await
.unwrap();
assert!(!Arc::ptr_eq(&suggestions, &changed_suggestions));
}

View File

@@ -0,0 +1,181 @@
//! Regression coverage for the fields that affect plugin behavior.
use super::*;
use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
use crate::remote::group_remote_installed_plugins_by_marketplaces;
use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginAvailability;
use codex_app_server_protocol::PluginInstallPolicy;
use serde_json::json;
fn image_url(renewal: &str) -> String {
format!("https://files.openai.com/plugins/icon.png?sig={renewal}")
}
fn plugin(renewal: &str) -> RemoteInstalledPlugin {
RemoteInstalledPlugin {
marketplace_name: REMOTE_GLOBAL_MARKETPLACE_NAME.to_string(),
id: "plugin-test".to_string(),
version: Some("1.0.0".to_string()),
name: "test".to_string(),
canonical_app_id: None,
installed_at: None,
enabled: true,
install_policy: PluginInstallPolicy::Available,
install_policy_source: None,
must_show_installation_interstitial: None,
auth_policy: PluginAuthPolicy::OnUse,
availability: PluginAvailability::Available,
disabled_reason: None,
eligible_plan_types: None,
interface: Some(
serde_json::from_value(json!({
"capabilities": [], "screenshots": [],
"logoUrl": image_url(renewal), "logoUrlDark": image_url(renewal),
"composerIconUrl": image_url(renewal), "screenshotUrls": [image_url(renewal)]
}))
.unwrap(),
),
keywords: vec![],
}
}
fn catalog(plugins: &[RemoteInstalledPlugin]) -> Vec<RemoteMarketplace> {
group_remote_installed_plugins_by_marketplaces(plugins, &[REMOTE_GLOBAL_MARKETPLACE_NAME])
}
#[test]
fn behavioral_metadata_changes_still_invalidate() {
let changes: &[fn(&mut RemoteInstalledPlugin)] = &[
|plugin| plugin.id.push_str("-other"),
|plugin| plugin.name.push_str("-other"),
|plugin| plugin.version = Some("2.0.0".to_string()),
|plugin| {
plugin.installed_at =
chrono::DateTime::from_timestamp(/*secs*/ 1, /*nsecs*/ 0)
},
|plugin| plugin.enabled = false,
|plugin| plugin.install_policy = PluginInstallPolicy::NotAvailable,
|plugin| {
plugin.install_policy_source =
Some(codex_app_server_protocol::PluginInstallPolicySource::WorkspaceSetting)
},
|plugin| plugin.must_show_installation_interstitial = Some(true),
|plugin| plugin.auth_policy = PluginAuthPolicy::OnInstall,
|plugin| plugin.availability = PluginAvailability::DisabledByAdmin,
|plugin| plugin.eligible_plan_types = Some(vec!["enterprise".to_string()]),
];
for change in changes {
let previous = [plugin("old")];
let mut current = [plugin("new")];
change(&mut current[0]);
assert!(!installed_plugin_metadata_eq(&previous, &current));
assert!(!remote_catalog_metadata_eq(
&catalog(&previous),
&catalog(&current)
));
}
let previous = [plugin("old")];
let mut current = [plugin("new")];
current[0].canonical_app_id = Some("new-app".to_string());
assert!(!installed_plugin_metadata_eq(&previous, &current));
assert!(!installed_plugin_metadata_eq(&previous, &[]));
assert!(!remote_catalog_metadata_eq(&catalog(&previous), &[]));
}
#[test]
fn display_metadata_does_not_invalidate() {
let previous = [plugin("old")];
let mut current = [plugin("new")];
current[0].keywords.push("new keyword".to_string());
let interface = current[0].interface.as_mut().unwrap();
interface.logo_url = Some("https://another-host.test/different-image.svg".to_string());
interface.display_name = Some("New display name".to_string());
interface.short_description = Some("New description".to_string());
interface.brand_color = Some("#123456".to_string());
interface.default_prompt = Some(vec!["New starter prompt".to_string()]);
interface.website_url = Some("https://another-host.test".to_string());
assert_ne!(previous, current);
assert!(installed_plugin_metadata_eq(&previous, &current));
let mut current_catalog = catalog(&current);
current_catalog[0].display_name = "New marketplace title".to_string();
assert!(remote_catalog_metadata_eq(
&catalog(&previous),
&current_catalog
));
current[0].interface = None;
assert!(installed_plugin_metadata_eq(&previous, &current));
assert!(remote_catalog_metadata_eq(
&catalog(&previous),
&catalog(&current)
));
}
#[test]
fn installed_ordering_remains_significant() {
let mut other = plugin("old");
other.id.push_str("-other");
other.name.push_str("-other");
let previous = [plugin("old"), other];
let mut current = previous.clone();
current.reverse();
assert!(!installed_plugin_metadata_eq(&previous, &current));
}
#[test]
fn store_badge_changes_do_not_invalidate() {
let mut previous = [plugin("old")];
previous[0].interface.as_mut().unwrap().capabilities =
vec!["tools".to_string(), "skills".to_string()];
for badges in [vec!["skills", "tools"], vec!["updated badge"], vec![]] {
let mut current = previous.clone();
current[0].interface.as_mut().unwrap().capabilities =
badges.into_iter().map(str::to_string).collect();
assert_ne!(previous, current);
assert!(installed_plugin_metadata_eq(&previous, &current));
assert!(remote_catalog_metadata_eq(
&catalog(&previous),
&catalog(&current)
));
}
}
#[test]
fn catalog_display_reordering_does_not_invalidate() {
let mut first = plugin("old");
first.interface.as_mut().unwrap().display_name = Some("Alpha".to_string());
let mut second = plugin("old");
second.id.push_str("-second");
second.name.push_str("-second");
second.interface.as_mut().unwrap().display_name = Some("Beta".to_string());
let previous = [first, second];
let mut current = previous.clone();
current[0].interface.as_mut().unwrap().display_name = Some("Zulu".to_string());
let previous_catalog = catalog(&previous);
let current_catalog = catalog(&current);
assert_ne!(
previous_catalog[0].plugins[0].id,
current_catalog[0].plugins[0].id
);
assert!(installed_plugin_metadata_eq(&previous, &current));
assert!(remote_catalog_metadata_eq(
&previous_catalog,
&current_catalog
));
current[0].version = Some("2.0.0".to_string());
assert!(!remote_catalog_metadata_eq(
&previous_catalog,
&catalog(&current)
));
assert!(!remote_catalog_metadata_eq(
&previous_catalog,
&catalog(&current[1..])
));
current[0] = current[1].clone();
assert!(!remote_catalog_metadata_eq(
&previous_catalog,
&catalog(&current)
));
}