merge feature/ticket120-hello-plugin-blackscreen-reinvestigation dans develop (#120: isolation plugin invalide + réconciliation MCP durcie + loader export default/cleanup)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -507,7 +507,7 @@ async fn install_from_staged(
|
|||||||
.await
|
.await
|
||||||
.map_err(map_store)?;
|
.map_err(map_store)?;
|
||||||
let mut registry = registry_store.load_registry().await.map_err(map_registry)?;
|
let mut registry = registry_store.load_registry().await.map_err(map_registry)?;
|
||||||
let entry = PluginRegistryEntry {
|
let mut entry = PluginRegistryEntry {
|
||||||
id: plugin_id.clone(),
|
id: plugin_id.clone(),
|
||||||
lifecycle_state: PluginLifecycleState::Enabled,
|
lifecycle_state: PluginLifecycleState::Enabled,
|
||||||
source: review.source.clone(),
|
source: review.source.clone(),
|
||||||
@ -516,6 +516,15 @@ async fn install_from_staged(
|
|||||||
restart_required: true,
|
restart_required: true,
|
||||||
error: None,
|
error: None,
|
||||||
};
|
};
|
||||||
|
if let Err(err) = runtime_plugin_from_entry(packages, validator, entry.clone()).await {
|
||||||
|
let message = format!(
|
||||||
|
"runtime contributions disabled: plugin `{}` is not servable: {err}",
|
||||||
|
plugin_id.as_str()
|
||||||
|
);
|
||||||
|
crate::diag!("[plugins] {message}");
|
||||||
|
entry.lifecycle_state = PluginLifecycleState::Invalid;
|
||||||
|
entry.error = Some(message);
|
||||||
|
}
|
||||||
registry.upsert(entry.clone());
|
registry.upsert(entry.clone());
|
||||||
registry_store
|
registry_store
|
||||||
.save_registry(®istry)
|
.save_registry(®istry)
|
||||||
@ -525,9 +534,10 @@ async fn install_from_staged(
|
|||||||
plugin_id: plugin_id.clone(),
|
plugin_id: plugin_id.clone(),
|
||||||
version: review.manifest.version.clone(),
|
version: review.manifest.version.clone(),
|
||||||
});
|
});
|
||||||
let _ = mcp
|
let (active_servers, invalid_plugins) =
|
||||||
.reconcile(active_mcp_specs(packages, validator, ®istry).await?)
|
active_mcp_specs(packages, validator, ®istry).await?;
|
||||||
.await;
|
persist_invalid_runtime_plugins(registry_store, &mut registry, invalid_plugins).await;
|
||||||
|
let _ = mcp.reconcile(active_servers).await;
|
||||||
let admin = admin_from_descriptor(
|
let admin = admin_from_descriptor(
|
||||||
PluginDescriptor {
|
PluginDescriptor {
|
||||||
manifest: review.manifest.clone(),
|
manifest: review.manifest.clone(),
|
||||||
@ -611,13 +621,11 @@ impl SetPluginEnabled {
|
|||||||
restart_required: true,
|
restart_required: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let _ = self
|
let (active_servers, invalid_plugins) =
|
||||||
.mcp
|
active_mcp_specs(self.packages.as_ref(), self.validator.as_ref(), ®istry).await?;
|
||||||
.reconcile(
|
persist_invalid_runtime_plugins(self.registry.as_ref(), &mut registry, invalid_plugins)
|
||||||
active_mcp_specs(self.packages.as_ref(), self.validator.as_ref(), ®istry)
|
|
||||||
.await?,
|
|
||||||
)
|
|
||||||
.await;
|
.await;
|
||||||
|
let _ = self.mcp.reconcile(active_servers).await;
|
||||||
let descriptor =
|
let descriptor =
|
||||||
descriptor_for(self.packages.as_ref(), self.validator.as_ref(), saved).await?;
|
descriptor_for(self.packages.as_ref(), self.validator.as_ref(), saved).await?;
|
||||||
admin_from_descriptor(descriptor, self.packages.as_ref())
|
admin_from_descriptor(descriptor, self.packages.as_ref())
|
||||||
@ -833,9 +841,11 @@ impl ReconcilePluginMcpServers {
|
|||||||
|
|
||||||
/// Executes the use case.
|
/// Executes the use case.
|
||||||
pub async fn execute(&self) -> Result<domain::PluginMcpStatusSet, AppError> {
|
pub async fn execute(&self) -> Result<domain::PluginMcpStatusSet, AppError> {
|
||||||
let registry = self.registry.load_registry().await.map_err(map_registry)?;
|
let mut registry = self.registry.load_registry().await.map_err(map_registry)?;
|
||||||
let specs =
|
let (specs, invalid_plugins) =
|
||||||
active_mcp_specs(self.packages.as_ref(), self.validator.as_ref(), ®istry).await?;
|
active_mcp_specs(self.packages.as_ref(), self.validator.as_ref(), ®istry).await?;
|
||||||
|
persist_invalid_runtime_plugins(self.registry.as_ref(), &mut registry, invalid_plugins)
|
||||||
|
.await;
|
||||||
self.mcp.reconcile(specs).await.map_err(map_mcp)
|
self.mcp.reconcile(specs).await.map_err(map_mcp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -844,7 +854,7 @@ async fn active_mcp_specs(
|
|||||||
packages: &dyn PluginPackageStore,
|
packages: &dyn PluginPackageStore,
|
||||||
validator: &dyn PluginManifestValidator,
|
validator: &dyn PluginManifestValidator,
|
||||||
registry: &domain::PluginRegistry,
|
registry: &domain::PluginRegistry,
|
||||||
) -> Result<Vec<PluginMcpServerSpec>, AppError> {
|
) -> Result<(Vec<PluginMcpServerSpec>, Vec<(PluginId, String)>), AppError> {
|
||||||
let installed_roots = packages
|
let installed_roots = packages
|
||||||
.list_installed()
|
.list_installed()
|
||||||
.await
|
.await
|
||||||
@ -854,11 +864,23 @@ async fn active_mcp_specs(
|
|||||||
.collect::<std::collections::HashMap<_, _>>();
|
.collect::<std::collections::HashMap<_, _>>();
|
||||||
let app_data_dir = packages.app_data_dir_label();
|
let app_data_dir = packages.app_data_dir_label();
|
||||||
let mut specs = Vec::new();
|
let mut specs = Vec::new();
|
||||||
|
let mut invalid_plugins = Vec::new();
|
||||||
for entry in ®istry.plugins {
|
for entry in ®istry.plugins {
|
||||||
if !entry.lifecycle_state.is_runtime_active() {
|
if !entry.lifecycle_state.is_runtime_active() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let descriptor = descriptor_for(packages, validator, entry.clone()).await?;
|
let descriptor = match descriptor_for(packages, validator, entry.clone()).await {
|
||||||
|
Ok(descriptor) => descriptor,
|
||||||
|
Err(err) => {
|
||||||
|
let message = format!(
|
||||||
|
"MCP servers disabled: plugin `{}` is not servable: {err}",
|
||||||
|
entry.id.as_str()
|
||||||
|
);
|
||||||
|
crate::diag!("[plugins] {message}");
|
||||||
|
invalid_plugins.push((entry.id.clone(), message));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
let plugin_root = installed_roots
|
let plugin_root = installed_roots
|
||||||
.get(&descriptor.manifest.id)
|
.get(&descriptor.manifest.id)
|
||||||
.cloned()
|
.cloned()
|
||||||
@ -907,7 +929,30 @@ async fn active_mcp_specs(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(specs)
|
Ok((specs, invalid_plugins))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn persist_invalid_runtime_plugins(
|
||||||
|
registry_store: &dyn PluginRegistryStore,
|
||||||
|
registry: &mut domain::PluginRegistry,
|
||||||
|
invalid_plugins: Vec<(PluginId, String)>,
|
||||||
|
) {
|
||||||
|
if invalid_plugins.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (plugin_id, message) in invalid_plugins {
|
||||||
|
if let Some(entry) = registry.plugins.iter_mut().find(|p| p.id == plugin_id) {
|
||||||
|
entry.lifecycle_state = PluginLifecycleState::Invalid;
|
||||||
|
entry.error = Some(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Err(err) = registry_store
|
||||||
|
.save_registry(registry)
|
||||||
|
.await
|
||||||
|
.map_err(map_registry)
|
||||||
|
{
|
||||||
|
crate::diag!("[plugins] failed to persist invalid runtime plugin state: {err}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn substitute_vars(raw: &str, plugin_root: &str, app_data_dir: Option<&str>) -> String {
|
fn substitute_vars(raw: &str, plugin_root: &str, app_data_dir: Option<&str>) -> String {
|
||||||
@ -1668,6 +1713,38 @@ mod tests {
|
|||||||
assert!(reconciles[0].is_empty());
|
assert!(reconciles[0].is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reconcile_mcp_marks_invalid_active_plugin_and_keeps_reconcile_alive() {
|
||||||
|
let packages = Arc::new(FakePackages::with_manifest(br#"{"broken":true}"#.to_vec()));
|
||||||
|
let registry = Arc::new(FakeRegistry {
|
||||||
|
registry: Mutex::new(registry_with(PluginLifecycleState::Enabled)),
|
||||||
|
});
|
||||||
|
let mcp = Arc::new(FakeMcp::default());
|
||||||
|
let usecase = ReconcilePluginMcpServers::new(
|
||||||
|
packages,
|
||||||
|
registry.clone(),
|
||||||
|
Arc::new(validator()),
|
||||||
|
mcp.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let statuses = usecase.execute().await.unwrap();
|
||||||
|
|
||||||
|
assert!(statuses.servers.is_empty());
|
||||||
|
assert_eq!(mcp.reconciles.lock().unwrap().len(), 1);
|
||||||
|
assert!(mcp.reconciles.lock().unwrap()[0].is_empty());
|
||||||
|
let saved = registry.load_registry().await.unwrap();
|
||||||
|
let entry = saved.find(&plugin_id()).unwrap();
|
||||||
|
assert_eq!(entry.lifecycle_state, PluginLifecycleState::Invalid);
|
||||||
|
assert!(
|
||||||
|
entry
|
||||||
|
.error
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("MCP servers disabled"),
|
||||||
|
"registry must carry a confined MCP error: {entry:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn reconcile_mcp_substitutes_app_data_dir_in_plugin_server_specs() {
|
async fn reconcile_mcp_substitutes_app_data_dir_in_plugin_server_specs() {
|
||||||
let mut manifest: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap();
|
let mut manifest: serde_json::Value = serde_json::from_slice(&valid_manifest()).unwrap();
|
||||||
|
|||||||
@ -255,6 +255,7 @@ impl PluginPackageStore for FsPluginPackageStore {
|
|||||||
entry: &RelativePath,
|
entry: &RelativePath,
|
||||||
hash: &ContentHash,
|
hash: &ContentHash,
|
||||||
) -> Result<PluginBundleUrl, PluginStoreError> {
|
) -> Result<PluginBundleUrl, PluginStoreError> {
|
||||||
|
self.resolve_asset_path(plugin_id, entry)?;
|
||||||
Ok(PluginBundleUrl::new(format!(
|
Ok(PluginBundleUrl::new(format!(
|
||||||
"idea-plugin://{}/current/{}{}{}",
|
"idea-plugin://{}/current/{}{}{}",
|
||||||
plugin_id.as_str(),
|
plugin_id.as_str(),
|
||||||
|
|||||||
@ -146,3 +146,77 @@ async fn installs_sdk_hello_plugin_and_loads_runtime_catalog() {
|
|||||||
|
|
||||||
let _ = fs::remove_dir_all(app_data);
|
let _ = fs::remove_dir_all(app_data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn installed_plugin_with_missing_main_is_isolated_from_runtime_catalog() {
|
||||||
|
let app_data = temp_dir("missing-main-app-data");
|
||||||
|
let source = temp_dir("missing-main-source");
|
||||||
|
fs::write(
|
||||||
|
source.join("idea-plugin.json"),
|
||||||
|
r#"{
|
||||||
|
"ideaPluginManifestVersion": 1,
|
||||||
|
"id": "dev.idea.fixtures.missing-main",
|
||||||
|
"displayName": "Missing Main Plugin",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"trustLevel": "full",
|
||||||
|
"capabilities": ["ui"],
|
||||||
|
"contributes": {}
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let packages = Arc::new(FsPluginPackageStore::new(&app_data));
|
||||||
|
let registry = Arc::new(FsPluginRegistryStore::new(&app_data));
|
||||||
|
let validator = Arc::new(JsonPluginManifestValidator::new("0.3.0"));
|
||||||
|
let events = Arc::new(TokioBroadcastEventBus::new());
|
||||||
|
let mcp = Arc::new(ExternalMcpPluginSupervisor::new());
|
||||||
|
|
||||||
|
let install = InstallPluginFromDirectory::new(
|
||||||
|
packages.clone(),
|
||||||
|
registry.clone(),
|
||||||
|
validator.clone(),
|
||||||
|
events,
|
||||||
|
mcp,
|
||||||
|
);
|
||||||
|
let result = install
|
||||||
|
.execute(source.to_string_lossy().into_owned())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result.plugin.id, "dev.idea.fixtures.missing-main");
|
||||||
|
assert_eq!(
|
||||||
|
result.plugin.lifecycle_state,
|
||||||
|
domain::PluginLifecycleState::Invalid
|
||||||
|
);
|
||||||
|
assert!(result
|
||||||
|
.plugin
|
||||||
|
.error
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("not servable"));
|
||||||
|
|
||||||
|
let catalog = ListPluginRuntimeContributions::new(packages, registry.clone(), validator)
|
||||||
|
.execute()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(catalog.plugins.is_empty());
|
||||||
|
let admin = ListPlugins::new(
|
||||||
|
Arc::new(FsPluginPackageStore::new(&app_data)),
|
||||||
|
registry,
|
||||||
|
Arc::new(JsonPluginManifestValidator::new("0.3.0")),
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
admin[0].lifecycle_state,
|
||||||
|
domain::PluginLifecycleState::Invalid
|
||||||
|
);
|
||||||
|
assert!(admin[0]
|
||||||
|
.error
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("not servable"));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(source);
|
||||||
|
let _ = fs::remove_dir_all(app_data);
|
||||||
|
}
|
||||||
|
|||||||
@ -38,6 +38,13 @@ const EMPTY_PLUGIN_RUNTIME: PluginRuntimeContextValue = {
|
|||||||
|
|
||||||
const PluginRuntimeContext = createContext<PluginRuntimeContextValue>(EMPTY_PLUGIN_RUNTIME);
|
const PluginRuntimeContext = createContext<PluginRuntimeContextValue>(EMPTY_PLUGIN_RUNTIME);
|
||||||
|
|
||||||
|
function describeError(e: unknown): string {
|
||||||
|
if (e && typeof e === "object" && "message" in e) {
|
||||||
|
return String((e as { message: unknown }).message);
|
||||||
|
}
|
||||||
|
return String(e);
|
||||||
|
}
|
||||||
|
|
||||||
interface PluginRuntimeProviderProps {
|
interface PluginRuntimeProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
/** Test/Storybook escape hatch — skips the gateway fetch and uses this value as-is. */
|
/** Test/Storybook escape hatch — skips the gateway fetch and uses this value as-is. */
|
||||||
@ -72,10 +79,19 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setValue({ registry: result.registry, failures: result.failures, loading: false });
|
setValue({ registry: result.registry, failures: result.failures, loading: false });
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((e: unknown) => {
|
||||||
// No plugin gateway / catalog fetch failed: run with zero plugins
|
// No plugin gateway / catalog fetch failed: run with zero plugins
|
||||||
// rather than blocking the app (full-trust plugins are additive).
|
// rather than blocking the app (full-trust plugins are additive).
|
||||||
if (!cancelled) setValue((prev) => ({ ...prev, loading: false }));
|
if (!cancelled) {
|
||||||
|
setValue((prev) => ({
|
||||||
|
...prev,
|
||||||
|
failures: [
|
||||||
|
...prev.failures,
|
||||||
|
{ pluginId: "<runtime-catalog>", reason: describeError(e) },
|
||||||
|
],
|
||||||
|
loading: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
|||||||
@ -167,7 +167,11 @@ export function PluginsPanel() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
{p.enabled ? (
|
{p.lifecycleState === "invalid" ? (
|
||||||
|
<Button size="sm" variant="ghost" disabled>
|
||||||
|
Isolé
|
||||||
|
</Button>
|
||||||
|
) : p.enabled ? (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { describe, it, expect } from "vitest";
|
|||||||
import { render, screen, waitFor, fireEvent, within } from "@testing-library/react";
|
import { render, screen, waitFor, fireEvent, within } from "@testing-library/react";
|
||||||
|
|
||||||
import { MockPluginGateway, MockSystemGateway } from "@/adapters/mock";
|
import { MockPluginGateway, MockSystemGateway } from "@/adapters/mock";
|
||||||
|
import type { PluginInstallResult, PluginRuntimeContributionCatalog } from "@/domain";
|
||||||
import type { Gateways } from "@/ports";
|
import type { Gateways } from "@/ports";
|
||||||
import { DIProvider } from "@/app/di";
|
import { DIProvider } from "@/app/di";
|
||||||
import { PluginRuntimeRegistry } from "@/plugins/runtime";
|
import { PluginRuntimeRegistry } from "@/plugins/runtime";
|
||||||
@ -38,6 +39,44 @@ function renderPanel(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderPanelWithLiveRuntime(plugin: MockPluginGateway, system = new MockSystemGateway()) {
|
||||||
|
const gateways = { plugin, system } as unknown as Gateways;
|
||||||
|
return render(
|
||||||
|
<DIProvider gateways={gateways}>
|
||||||
|
<PluginRuntimeProvider>
|
||||||
|
<PluginsPanel />
|
||||||
|
</PluginRuntimeProvider>
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class InvalidInstallPluginGateway extends MockPluginGateway {
|
||||||
|
async installFromDirectory(path: string): Promise<PluginInstallResult> {
|
||||||
|
const plugin = {
|
||||||
|
id: "dev.idea.fixtures.missing-main",
|
||||||
|
displayName: "Missing Main Plugin",
|
||||||
|
version: "0.1.0",
|
||||||
|
sourceKind: "directory" as const,
|
||||||
|
sourceLabel: path,
|
||||||
|
lifecycleState: "invalid" as const,
|
||||||
|
enabled: false,
|
||||||
|
pendingUninstall: false,
|
||||||
|
restartRequired: false,
|
||||||
|
trustLevel: "full" as const,
|
||||||
|
contributionSummary: { topLevelMenus: 1, menuItems: 1, layouts: 0, mcpServers: 0 },
|
||||||
|
error: "plugin main asset is missing: dist/index.js",
|
||||||
|
};
|
||||||
|
this._seedPlugin(plugin);
|
||||||
|
return { plugin, restartRequired: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FailingRuntimeCatalogPluginGateway extends MockPluginGateway {
|
||||||
|
async listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog> {
|
||||||
|
throw { code: "INVALID", message: "runtime catalog failed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("PluginsPanel", () => {
|
describe("PluginsPanel", () => {
|
||||||
it("shows an empty state with no plugins installed", async () => {
|
it("shows an empty state with no plugins installed", async () => {
|
||||||
renderPanel();
|
renderPanel();
|
||||||
@ -57,6 +96,15 @@ describe("PluginsPanel", () => {
|
|||||||
expect(screen.getByText(/Cannot use import statement outside a module/)).toBeTruthy();
|
expect(screen.getByText(/Cannot use import statement outside a module/)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the Plugins panel rendered when the runtime catalog fetch fails", async () => {
|
||||||
|
renderPanelWithLiveRuntime(new FailingRuntimeCatalogPluginGateway());
|
||||||
|
|
||||||
|
expect(await screen.findByText("Aucun plugin installé.")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Certains plugins installés n'ont pas pu être chargés.")).toBeTruthy();
|
||||||
|
expect(screen.getByText("<runtime-catalog>")).toBeTruthy();
|
||||||
|
expect(screen.getByText(/runtime catalog failed/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it("installs from an archive via the review dialog, mentioning full-trust", async () => {
|
it("installs from an archive via the review dialog, mentioning full-trust", async () => {
|
||||||
renderPanel();
|
renderPanel();
|
||||||
await screen.findByText("Aucun plugin installé.");
|
await screen.findByText("Aucun plugin installé.");
|
||||||
@ -74,6 +122,21 @@ describe("PluginsPanel", () => {
|
|||||||
expect(screen.getByText("Activé")).toBeTruthy();
|
expect(screen.getByText("Activé")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders an invalid plugin returned after install as isolated, without replacing the panel", async () => {
|
||||||
|
renderPanel(new InvalidInstallPluginGateway());
|
||||||
|
await screen.findByText("Aucun plugin installé.");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Installer depuis un dossier…" }));
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Installer" }));
|
||||||
|
|
||||||
|
expect(await screen.findByText("Missing Main Plugin")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Invalide")).toBeTruthy();
|
||||||
|
expect(screen.getByText("plugin main asset is missing: dist/index.js")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button", { name: "Isolé" }).hasAttribute("disabled")).toBe(true);
|
||||||
|
expect(screen.queryByRole("button", { name: "Activer" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("disables an installed plugin after confirmation", async () => {
|
it("disables an installed plugin after confirmation", async () => {
|
||||||
const plugin = new MockPluginGateway();
|
const plugin = new MockPluginGateway();
|
||||||
plugin._seedPlugin({
|
plugin._seedPlugin({
|
||||||
|
|||||||
@ -52,6 +52,26 @@ describe("loadPlugins", () => {
|
|||||||
expect((globalThis as Record<string, unknown>).__activatedWith).toBe("dev.acme.one");
|
expect((globalThis as Record<string, unknown>).__activatedWith).toBe("dev.acme.one");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads an SDK-style default export containing activate(ctx)", async () => {
|
||||||
|
const bundle = dataUrl(`
|
||||||
|
export default {
|
||||||
|
activate(ctx) {
|
||||||
|
globalThis.__defaultExportActivatedWith = ctx.pluginId;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
`);
|
||||||
|
const { registry, failures } = await loadPlugins(
|
||||||
|
[entry({ id: "com.example.hello-plugin", displayName: "Hello Plugin", bundleUrl: bundle })],
|
||||||
|
gateways,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(failures).toEqual([]);
|
||||||
|
expect(registry.list().map((p) => p.pluginId)).toEqual(["com.example.hello-plugin"]);
|
||||||
|
expect((globalThis as Record<string, unknown>).__defaultExportActivatedWith).toBe(
|
||||||
|
"com.example.hello-plugin",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("refuses to register a command not declared in the manifest", async () => {
|
it("refuses to register a command not declared in the manifest", async () => {
|
||||||
const bundle = dataUrl(`
|
const bundle = dataUrl(`
|
||||||
export function activate(ctx) {
|
export function activate(ctx) {
|
||||||
@ -259,6 +279,32 @@ describe("loadPlugins", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("disposes partial subscriptions when activation fails", async () => {
|
||||||
|
const bundle = dataUrl(`
|
||||||
|
export function activate(ctx) {
|
||||||
|
ctx.subscriptions.push({
|
||||||
|
dispose() {
|
||||||
|
globalThis.__partialActivationDisposed = ctx.pluginId;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
throw new Error("activation failed");
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const { registry, failures } = await loadPlugins(
|
||||||
|
[entry({ id: "dev.acme.partial", displayName: "Partial", bundleUrl: bundle })],
|
||||||
|
gateways,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(registry.list()).toEqual([]);
|
||||||
|
expect(failures).toEqual([
|
||||||
|
{ pluginId: "dev.acme.partial", reason: "activation failed" },
|
||||||
|
]);
|
||||||
|
expect((globalThis as Record<string, unknown>).__partialActivationDisposed).toBe(
|
||||||
|
"dev.acme.partial",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("only loads what the catalog contains — a disabled/absent plugin is simply never in it", async () => {
|
it("only loads what the catalog contains — a disabled/absent plugin is simply never in it", async () => {
|
||||||
// The backend contract (carnet §1.3) filters the catalog to
|
// The backend contract (carnet §1.3) filters the catalog to
|
||||||
// `enabled && !pendingUninstall` before the loader ever sees it; the
|
// `enabled && !pendingUninstall` before the loader ever sees it; the
|
||||||
|
|||||||
@ -81,6 +81,12 @@ function isIdeaPluginModule(mod: unknown): mod is IdeaPluginModule {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveIdeaPluginModule(mod: unknown): IdeaPluginModule | null {
|
||||||
|
if (isIdeaPluginModule(mod)) return mod;
|
||||||
|
const defaultExport = objectOrEmpty(mod).default;
|
||||||
|
return isIdeaPluginModule(defaultExport) ? defaultExport : null;
|
||||||
|
}
|
||||||
|
|
||||||
function createPluginLogger(entry: PluginRuntimePlugin): PluginLogger {
|
function createPluginLogger(entry: PluginRuntimePlugin): PluginLogger {
|
||||||
const prefix = `[plugin:${safePluginId(entry)}]`;
|
const prefix = `[plugin:${safePluginId(entry)}]`;
|
||||||
return {
|
return {
|
||||||
@ -118,19 +124,19 @@ function safePluginId(entry: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function commandIdsFromContributes(contributes: PluginContributionDto): Set<string> {
|
function commandIdsFromContributes(contributes: PluginContributionDto): Set<string> {
|
||||||
return new Set(
|
return new Set<string>(
|
||||||
contributes.menuItems.flatMap((item) => {
|
contributes.menuItems.flatMap<string>((item) => {
|
||||||
const command = objectOrEmpty(item).command;
|
const command = nonEmptyString(objectOrEmpty(item).command);
|
||||||
return nonEmptyString(command) ? [command] : [];
|
return command ? [command] : [];
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function layoutTypesFromContributes(contributes: PluginContributionDto): Set<string> {
|
function layoutTypesFromContributes(contributes: PluginContributionDto): Set<string> {
|
||||||
return new Set(
|
return new Set<string>(
|
||||||
contributes.layouts.flatMap((layout) => {
|
contributes.layouts.flatMap<string>((layout) => {
|
||||||
const type = objectOrEmpty(layout).type;
|
const type = nonEmptyString(objectOrEmpty(layout).type);
|
||||||
return nonEmptyString(type) ? [type] : [];
|
return type ? [type] : [];
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -169,6 +175,8 @@ async function loadOne(
|
|||||||
const pluginId = safePluginId(entry);
|
const pluginId = safePluginId(entry);
|
||||||
const displayName = nonEmptyString(entryObject.displayName) ?? pluginId;
|
const displayName = nonEmptyString(entryObject.displayName) ?? pluginId;
|
||||||
const version = nonEmptyString(entryObject.version) ?? "";
|
const version = nonEmptyString(entryObject.version) ?? "";
|
||||||
|
let activation: void | PluginActivation = undefined;
|
||||||
|
const subscriptions: Disposable[] = [];
|
||||||
try {
|
try {
|
||||||
// The bundle URL is a plugin-scoped, content-hashed local protocol URL
|
// The bundle URL is a plugin-scoped, content-hashed local protocol URL
|
||||||
// served by the backend (carnet §1.3) — never a disk path or arbitrary
|
// served by the backend (carnet §1.3) — never a disk path or arbitrary
|
||||||
@ -178,8 +186,8 @@ async function loadOne(
|
|||||||
throw new Error("missing plugin bundle URL");
|
throw new Error("missing plugin bundle URL");
|
||||||
}
|
}
|
||||||
|
|
||||||
const mod: unknown = await import(/* @vite-ignore */ bundleUrl);
|
const mod = resolveIdeaPluginModule(await import(/* @vite-ignore */ bundleUrl));
|
||||||
if (!isIdeaPluginModule(mod)) {
|
if (!mod) {
|
||||||
return {
|
return {
|
||||||
failure: {
|
failure: {
|
||||||
pluginId,
|
pluginId,
|
||||||
@ -194,7 +202,6 @@ async function loadOne(
|
|||||||
const commands = new PluginCommandRegistry(pluginId, declaredCommandIds);
|
const commands = new PluginCommandRegistry(pluginId, declaredCommandIds);
|
||||||
const layouts = new PluginLayoutRegistry(pluginId, declaredLayoutTypes);
|
const layouts = new PluginLayoutRegistry(pluginId, declaredLayoutTypes);
|
||||||
const menu = new PluginMenuRegistry(pluginId);
|
const menu = new PluginMenuRegistry(pluginId);
|
||||||
const subscriptions: Disposable[] = [];
|
|
||||||
|
|
||||||
const ctx: IdeaPluginContext = {
|
const ctx: IdeaPluginContext = {
|
||||||
pluginId,
|
pluginId,
|
||||||
@ -208,7 +215,7 @@ async function loadOne(
|
|||||||
...gateways,
|
...gateways,
|
||||||
};
|
};
|
||||||
|
|
||||||
const activation = await mod.activate(ctx);
|
activation = await mod.activate(ctx);
|
||||||
|
|
||||||
const plugin: LoadedPlugin = {
|
const plugin: LoadedPlugin = {
|
||||||
pluginId,
|
pluginId,
|
||||||
@ -225,6 +232,7 @@ async function loadOne(
|
|||||||
};
|
};
|
||||||
return { plugin };
|
return { plugin };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
await disposeAll(subscriptions, activation);
|
||||||
return {
|
return {
|
||||||
failure: {
|
failure: {
|
||||||
pluginId,
|
pluginId,
|
||||||
|
|||||||
Reference in New Issue
Block a user