diff --git a/.github/workflows/maven-test.yml b/.github/workflows/maven-test.yml index de03503..b6c86de 100644 --- a/.github/workflows/maven-test.yml +++ b/.github/workflows/maven-test.yml @@ -29,7 +29,12 @@ jobs: "xin.bbtt.mcbot.listeners.CommandsRecorderTest", "xin.bbtt.mcbot.plugin.PluginClassloaderLeakTest", "xin.bbtt.mcbot.config.BotConfigDataTest", - "xin.bbtt.mcbot.plugin.PluginYmlValidationTest" + "xin.bbtt.mcbot.plugin.PluginYmlValidationTest", + "xin.bbtt.mcbot.LoginFlow.LoginFlowTest", + "xin.bbtt.mcbot.LoginFlow.LoginFlowRealWorldTest", + "xin.bbtt.mcbot.modpack.ModpackTest", + "xin.bbtt.mcbot.plugin.MetaPluginDependencyTest", + "xin.bbtt.mcbot.plugin.PluginRuntimeSoftDependTest" ] steps: diff --git a/pom.xml b/pom.xml index 92c7a74..8cc89d9 100644 --- a/pom.xml +++ b/pom.xml @@ -23,7 +23,7 @@ xin.bbtt xinbot - 2.3.1-RELEASE + 2.3.2-SNAPSHOT jar diff --git a/src/main/java/xin/bbtt/mcbot/plugin/PluginManager.java b/src/main/java/xin/bbtt/mcbot/plugin/PluginManager.java index a3a3bd7..d9ab090 100644 --- a/src/main/java/xin/bbtt/mcbot/plugin/PluginManager.java +++ b/src/main/java/xin/bbtt/mcbot/plugin/PluginManager.java @@ -138,28 +138,20 @@ public void loadPlugin(File pluginFile) throws Exception { if (plugins.containsKey(info.name)) return; - PluginClassLoader pluginClassLoader = new PluginClassLoader(new URL[]{url}, PluginManager.class.getClassLoader()); - pluginLoaders.put(info.name, pluginClassLoader); - pluginDependencies.put(info.name, info.depends); - - Plugin plugin; - try { - Class clazz = Class.forName(info.mainClass, true, pluginClassLoader); - plugin = (Plugin) clazz.getDeclaredConstructor().newInstance(); - } catch (Exception e) { - pluginLoaders.remove(info.name); - pluginDependencies.remove(info.name); - try { pluginClassLoader.close(); } catch (IOException ignored) {} - throw e; - } - - RegisteredPlugin rp; - if (info.type == PluginType.META_PLUGIN && plugin instanceof MetaPlugin) { - rp = new RegisteredMetaPlugin(info.name, info.version, info.mainClass, info.depends, info.file, url, (MetaPlugin) plugin); - } else { - rp = new RegisteredPlugin(info.name, info.version, info.mainClass, info.depends, info.file, url, plugin, PluginType.PLUGIN); + // The startup batch loader refuses plugins with missing hard dependencies + // (topological sort excludes them); the runtime path must do the same instead + // of silently loading with a broken classloader chain. + for (String dep : info.depends) { + if (!plugins.containsKey(dep)) { + throw new IllegalArgumentException(LangManager.get("xinbot.plugin.dependency.missing", dep, info.name)); + } } - loadPlugin(rp); + + info.file = pluginFile; + info.url = url; + // Delegate to the shared loader so runtime loads (e.g. `pm load`/`pm reload`) + // wire up the (soft)dependency classloader chain just like startup batch loading. + instantiateAndLoad(info); } public void loadPlugins(String pluginsDirectory) { @@ -449,6 +441,10 @@ public void unloadPlugin(RegisteredPlugin rp) { return; } if (!plugins.containsKey(rp.getName())) return; + if (Bot.INSTANCE.isRunning() && isMetaPluginDependency(rp.getName())) { + log.error(LangManager.get("xinbot.metaplugin.error.unload_dependency_runtime", rp.getName())); + return; + } String pluginName = rp.getName(); unloadDependents(pluginName); @@ -475,6 +471,36 @@ private void unloadDependents(String pluginName) { } } + // A plugin the meta plugin (transitively) depends on shares the meta plugin's + // lifecycle restrictions: unloading it would tear down the meta plugin's + // classloader chain, so it must not be unloaded during runtime either. + private boolean isMetaPluginDependency(String pluginName) { + for (RegisteredPlugin p : plugins.values()) { + if (p instanceof RegisteredMetaPlugin + && getTransitiveDependencies(p.getName()).contains(pluginName)) { + return true; + } + } + return false; + } + + private List getTransitiveDependencies(String pluginName) { + List ordered = new ArrayList<>(); + Set visited = new HashSet<>(); + visited.add(pluginName); + collectDependencies(pluginName, visited, ordered); + return ordered; + } + + private void collectDependencies(String pluginName, Set visited, List ordered) { + for (String dep : pluginDependencies.getOrDefault(pluginName, Collections.emptyList())) { + if (visited.add(dep)) { + collectDependencies(dep, visited, ordered); + ordered.add(dep); + } + } + } + private List getDependents(String pluginName) { List dependents = new ArrayList<>(); for (RegisteredPlugin p : plugins.values()) { @@ -515,6 +541,11 @@ public void unloadPlugins() { public void enableAll() { RegisteredMetaPlugin meta = getMetaPlugin(); if (meta != null) { + // Dependencies must be enabled before the meta plugin itself. + for (String depName : getTransitiveDependencies(meta.getName())) { + RegisteredPlugin dep = plugins.get(depName); + if (dep != null) enablePlugin(dep); + } enablePlugin(meta); } for (RegisteredPlugin rp : plugins.values()) { diff --git a/src/main/resources/lang/de_de.lang b/src/main/resources/lang/de_de.lang index 8998ca8..736601e 100644 --- a/src/main/resources/lang/de_de.lang +++ b/src/main/resources/lang/de_de.lang @@ -36,6 +36,7 @@ xinbot.metaplugin.error.load_runtime=MetaPlugin kann während der Laufzeit nicht xinbot.metaplugin.error.enable_runtime=MetaPlugin kann während der Laufzeit nicht aktiviert werden! xinbot.metaplugin.error.disable_runtime=MetaPlugin kann während der Laufzeit nicht deaktiviert werden! xinbot.metaplugin.error.unload_runtime=MetaPlugin kann während der Laufzeit nicht entladen werden! +xinbot.metaplugin.error.unload_dependency_runtime=Plugin %s kann während der Laufzeit nicht entladen werden: das MetaPlugin hängt davon ab! xinbot.metaplugin.error.count=Der Bot muss genau ein MetaPlugin zum Starten haben! (Gefunden: %d) # LangManager messages diff --git a/src/main/resources/lang/en_us.lang b/src/main/resources/lang/en_us.lang index efa7aa5..98e1c2d 100644 --- a/src/main/resources/lang/en_us.lang +++ b/src/main/resources/lang/en_us.lang @@ -36,6 +36,7 @@ xinbot.metaplugin.error.load_runtime=Cannot load MetaPlugin during runtime! xinbot.metaplugin.error.enable_runtime=Cannot enable MetaPlugin during runtime! xinbot.metaplugin.error.disable_runtime=Cannot disable MetaPlugin during runtime! xinbot.metaplugin.error.unload_runtime=Cannot unload MetaPlugin during runtime! +xinbot.metaplugin.error.unload_dependency_runtime=Cannot unload plugin %s during runtime: the MetaPlugin depends on it! xinbot.metaplugin.error.count=Bot must have exactly one MetaPlugin to start! (Found: %d) # LangManager messages diff --git a/src/main/resources/lang/fr_fr.lang b/src/main/resources/lang/fr_fr.lang index e24e68b..68480a5 100644 --- a/src/main/resources/lang/fr_fr.lang +++ b/src/main/resources/lang/fr_fr.lang @@ -36,6 +36,7 @@ xinbot.metaplugin.error.load_runtime=Impossible de charger le MetaPlugin pendant xinbot.metaplugin.error.enable_runtime=Impossible d'activer le MetaPlugin pendant l'exécution ! xinbot.metaplugin.error.disable_runtime=Impossible de désactiver le MetaPlugin pendant l'exécution ! xinbot.metaplugin.error.unload_runtime=Impossible de décharger le MetaPlugin pendant l'exécution ! +xinbot.metaplugin.error.unload_dependency_runtime=Impossible de décharger le plugin %s pendant l'exécution : le MetaPlugin en dépend ! xinbot.metaplugin.error.count=Le bot doit avoir exactement un MetaPlugin pour démarrer ! (Trouvé : %d) # LangManager messages diff --git a/src/main/resources/lang/ja_jp.lang b/src/main/resources/lang/ja_jp.lang index d695572..4ba7e56 100644 --- a/src/main/resources/lang/ja_jp.lang +++ b/src/main/resources/lang/ja_jp.lang @@ -36,6 +36,7 @@ xinbot.metaplugin.error.load_runtime=実行中に MetaPlugin を読み込むこ xinbot.metaplugin.error.enable_runtime=実行中に MetaPlugin を有効にすることはできません! xinbot.metaplugin.error.disable_runtime=実行中に MetaPlugin を無効にすることはできません! xinbot.metaplugin.error.unload_runtime=実行中に MetaPlugin をアンロードすることはできません! +xinbot.metaplugin.error.unload_dependency_runtime=実行中にプラグイン %s をアンロードすることはできません。MetaPlugin が依存しています! xinbot.metaplugin.error.count=ボットを開始するには、正確に1つの MetaPlugin が必要です!(見つかった数: %d) # LangManager messages diff --git a/src/main/resources/lang/ru_ru.lang b/src/main/resources/lang/ru_ru.lang index f1c9a6d..9e5f997 100644 --- a/src/main/resources/lang/ru_ru.lang +++ b/src/main/resources/lang/ru_ru.lang @@ -36,6 +36,7 @@ xinbot.metaplugin.error.load_runtime=Невозможно загрузить Met xinbot.metaplugin.error.enable_runtime=Невозможно включить MetaPlugin во время работы! xinbot.metaplugin.error.disable_runtime=Невозможно отключить MetaPlugin во время работы! xinbot.metaplugin.error.unload_runtime=Невозможно выгрузить MetaPlugin во время работы! +xinbot.metaplugin.error.unload_dependency_runtime=Невозможно выгрузить плагин %s во время работы: от него зависит MetaPlugin! xinbot.metaplugin.error.count=Для запуска бота должен быть ровно один MetaPlugin! (Найдено: %d) # LangManager messages diff --git a/src/main/resources/lang/zh_cn.lang b/src/main/resources/lang/zh_cn.lang index b451012..1df3209 100644 --- a/src/main/resources/lang/zh_cn.lang +++ b/src/main/resources/lang/zh_cn.lang @@ -36,6 +36,7 @@ xinbot.metaplugin.error.load_runtime=不允许在运行过程中加载元插件 xinbot.metaplugin.error.enable_runtime=不允许在运行过程中启用元插件! xinbot.metaplugin.error.disable_runtime=不允许在运行过程中禁用元插件! xinbot.metaplugin.error.unload_runtime=不允许在运行过程中卸载元插件! +xinbot.metaplugin.error.unload_dependency_runtime=不允许在运行过程中卸载插件 %s:该插件被元插件依赖! xinbot.metaplugin.error.count=机器人启动必须有且仅有一个元插件!(当前找到:%d 个) # LangManager messages diff --git a/src/main/resources/lang/zh_tw.lang b/src/main/resources/lang/zh_tw.lang index 5322ffc..f00c1e2 100644 --- a/src/main/resources/lang/zh_tw.lang +++ b/src/main/resources/lang/zh_tw.lang @@ -31,6 +31,7 @@ xinbot.metaplugin.error.load_runtime=不允許在運行過程中載入元外掛 xinbot.metaplugin.error.enable_runtime=不允許在運行過程中啟用元外掛程式! xinbot.metaplugin.error.disable_runtime=不允許在運行過程中停用元外掛程式! xinbot.metaplugin.error.unload_runtime=不允許在運行過程中卸載元外掛程式! +xinbot.metaplugin.error.unload_dependency_runtime=不允許在運行過程中卸載外掛程式 %s:該外掛程式被元外掛程式依賴! xinbot.metaplugin.error.count=機器人啟動必須有且僅有一個元外掛程式!(當前找到:%d 個) xinbot.plugin.enabled=已啟用外掛程式:%s xinbot.plugin.disabled=已停用外掛程式:%s diff --git a/src/test/java/xin/bbtt/mcbot/BotTestState.java b/src/test/java/xin/bbtt/mcbot/BotTestState.java new file mode 100644 index 0000000..d484e6a --- /dev/null +++ b/src/test/java/xin/bbtt/mcbot/BotTestState.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2024-2026 huangdihd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package xin.bbtt.mcbot; + +import java.lang.reflect.Field; + +/** + * Test-only access to {@link Bot}'s private state ({@code running}, {@code session}), + * so plugin tests can simulate startup/runtime conditions on the Bot singleton. + * Production code intentionally exposes no setters for these fields, so the + * reflection is confined to this single helper. + */ +public final class BotTestState { + + private BotTestState() { + } + + public static void setRunning(boolean running) throws ReflectiveOperationException { + set("running", running); + } + + public static void clearSession() throws ReflectiveOperationException { + set("session", null); + } + + @SuppressWarnings("PMD.AvoidAccessibilityAlteration") + private static void set(String fieldName, Object value) throws ReflectiveOperationException { + Field field = Bot.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(Bot.INSTANCE, value); + } +} diff --git a/src/test/java/xin/bbtt/mcbot/plugin/DummyLibPlugin.java b/src/test/java/xin/bbtt/mcbot/plugin/DummyLibPlugin.java new file mode 100644 index 0000000..472d07a --- /dev/null +++ b/src/test/java/xin/bbtt/mcbot/plugin/DummyLibPlugin.java @@ -0,0 +1,24 @@ +package xin.bbtt.mcbot.plugin; + +public class DummyLibPlugin implements Plugin { + + @Override + public void onLoad() { + DummyMetaPlugin.events.add("lib-load"); + } + + @Override + public void onEnable() { + DummyMetaPlugin.events.add("lib-enable"); + } + + @Override + public void onDisable() { + DummyMetaPlugin.events.add("lib-disable"); + } + + @Override + public void onUnload() { + DummyMetaPlugin.events.add("lib-unload"); + } +} diff --git a/src/test/java/xin/bbtt/mcbot/plugin/DummyMetaPlugin.java b/src/test/java/xin/bbtt/mcbot/plugin/DummyMetaPlugin.java new file mode 100644 index 0000000..94cd31b --- /dev/null +++ b/src/test/java/xin/bbtt/mcbot/plugin/DummyMetaPlugin.java @@ -0,0 +1,48 @@ +package xin.bbtt.mcbot.plugin; + +import org.geysermc.mcprotocollib.protocol.packet.ingame.clientbound.ClientboundLoginPacket; +import xin.bbtt.mcbot.Server; + +import java.net.SocketAddress; +import java.util.ArrayList; +import java.util.List; + +public class DummyMetaPlugin implements MetaPlugin { + + // Shared lifecycle event log so tests can assert ordering across plugins. + public static final List events = new ArrayList<>(); + + @Override + public void onLoad() { + events.add("meta-load"); + } + + @Override + public void onEnable() { + events.add("meta-enable"); + } + + @Override + public void onDisable() { + events.add("meta-disable"); + } + + @Override + public void onUnload() { + events.add("meta-unload"); + } + + @Override + public SocketAddress getServerSocketAddress() { + return null; + } + + @Override + public Server getServer(ClientboundLoginPacket loginPacket) { + return null; + } + + public static void reset() { + events.clear(); + } +} diff --git a/src/test/java/xin/bbtt/mcbot/plugin/MetaPluginDependencyTest.java b/src/test/java/xin/bbtt/mcbot/plugin/MetaPluginDependencyTest.java new file mode 100644 index 0000000..b6ed652 --- /dev/null +++ b/src/test/java/xin/bbtt/mcbot/plugin/MetaPluginDependencyTest.java @@ -0,0 +1,129 @@ +package xin.bbtt.mcbot.plugin; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import xin.bbtt.mcbot.BotTestState; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.nio.file.Path; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests that a MetaPlugin can depend on regular plugins, and that those + * dependencies inherit the MetaPlugin's lifecycle restriction: they cannot be + * unloaded during runtime, because that would tear down the MetaPlugin's + * classloader chain. + */ +class MetaPluginDependencyTest { + + private PluginManager pluginManager; + + @TempDir + Path tempDir; + + @BeforeEach + void setUp() throws Exception { + DummyMetaPlugin.reset(); + pluginManager = new PluginManager(); + BotTestState.clearSession(); + BotTestState.setRunning(false); + } + + @AfterEach + void tearDown() throws Exception { + BotTestState.setRunning(false); + } + + private void createPluginJar(String fileName, String yml, Class mainClass) throws Exception { + File file = tempDir.resolve(fileName).toFile(); + try (JarOutputStream jos = new JarOutputStream(new FileOutputStream(file))) { + jos.putNextEntry(new JarEntry("plugin.yml")); + jos.write(yml.getBytes()); + jos.closeEntry(); + + String className = mainClass.getName().replace('.', '/') + ".class"; + jos.putNextEntry(new JarEntry(className)); + try (InputStream is = mainClass.getClassLoader().getResourceAsStream(className)) { + assertThat(is).isNotNull(); + byte[] buffer = new byte[1024]; + int len; + while ((len = is.read(buffer)) > 0) { + jos.write(buffer, 0, len); + } + } + jos.closeEntry(); + } + } + + private void loadMetaWithDependency() throws Exception { + createPluginJar("lib-plugin.jar", + "name: LibPlugin\nmain: xin.bbtt.mcbot.plugin.DummyLibPlugin\nversion: 1.0.0\n", + DummyLibPlugin.class); + createPluginJar("meta-plugin.jar", + "name: TestMeta\nmain: xin.bbtt.mcbot.plugin.DummyMetaPlugin\nversion: 1.0.0\n" + + "type: META_PLUGIN\ndepend: [LibPlugin]\n", + DummyMetaPlugin.class); + pluginManager.loadPlugins(tempDir.toString()); + } + + @Test + void metaPluginCanDependOnAnotherPlugin() throws Exception { + loadMetaWithDependency(); + + RegisteredPlugin meta = pluginManager.getPlugin("TestMeta"); + assertThat(meta).isInstanceOf(RegisteredMetaPlugin.class); + assertThat(pluginManager.getPlugin("LibPlugin")).isNotNull(); + assertThat(pluginManager.getPluginDependencies().get("TestMeta")).contains("LibPlugin"); + + // The dependency must be loaded first and be part of the meta plugin's + // classloader chain. + assertThat(DummyMetaPlugin.events.indexOf("lib-load")) + .isLessThan(DummyMetaPlugin.events.indexOf("meta-load")); + assertThat(pluginManager.getPluginLoader("TestMeta").getParent()) + .isSameAs(pluginManager.getPluginLoader("LibPlugin")); + } + + @Test + void enableAllEnablesMetaDependenciesFirst() throws Exception { + loadMetaWithDependency(); + + pluginManager.enableAll(); + + assertThat(pluginManager.isPluginEnabled("LibPlugin")).isTrue(); + assertThat(pluginManager.isPluginEnabled("TestMeta")).isTrue(); + assertThat(DummyMetaPlugin.events.indexOf("lib-enable")) + .isLessThan(DummyMetaPlugin.events.indexOf("meta-enable")); + } + + @Test + void metaPluginDependencyCannotBeUnloadedDuringRuntime() throws Exception { + loadMetaWithDependency(); + BotTestState.setRunning(true); + + pluginManager.unloadPlugin(pluginManager.getPlugin("LibPlugin")); + + assertThat(pluginManager.isPluginLoaded("LibPlugin")).isTrue(); + assertThat(pluginManager.isPluginLoaded("TestMeta")).isTrue(); + assertThat(DummyMetaPlugin.events).doesNotContain("lib-unload", "meta-unload"); + } + + @Test + void metaPluginDependencyUnloadsWhenNotRunning() throws Exception { + loadMetaWithDependency(); + + pluginManager.unloadPlugin(pluginManager.getPlugin("LibPlugin")); + + // The meta plugin depends on the lib, so it is unloaded first as a dependent. + assertThat(pluginManager.isPluginLoaded("LibPlugin")).isFalse(); + assertThat(pluginManager.isPluginLoaded("TestMeta")).isFalse(); + assertThat(DummyMetaPlugin.events.indexOf("meta-unload")) + .isLessThan(DummyMetaPlugin.events.indexOf("lib-unload")); + } +} diff --git a/src/test/java/xin/bbtt/mcbot/plugin/PluginRuntimeSoftDependTest.java b/src/test/java/xin/bbtt/mcbot/plugin/PluginRuntimeSoftDependTest.java new file mode 100644 index 0000000..c79add0 --- /dev/null +++ b/src/test/java/xin/bbtt/mcbot/plugin/PluginRuntimeSoftDependTest.java @@ -0,0 +1,120 @@ +package xin.bbtt.mcbot.plugin; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import xin.bbtt.mcbot.BotTestState; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.nio.file.Path; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Regression test for runtime plugin loading (e.g. the {@code pm load} / {@code pm reload} + * commands, which call {@link PluginManager#loadPlugin(File)} one JAR at a time). + * + *

The startup batch loader wires every plugin's classloader to the loaders of its + * (soft)dependencies, but the single-file runtime path used to skip that wiring entirely, + * so a plugin loaded at runtime could not see its (soft)dependency's classes. This test + * pins down that the runtime path now builds the same classloader chain. + */ +class PluginRuntimeSoftDependTest { + + private PluginManager pluginManager; + + @TempDir + Path tempDir; + + @BeforeEach + void setUp() throws Exception { + DummyPlugin.reset(); + pluginManager = new PluginManager(); + + // Ensure no session/running state leaks in from other tests so loading does not + // try to auto-enable the plugins (we only care about the classloader wiring here). + BotTestState.clearSession(); + BotTestState.setRunning(false); + } + + private File createPluginJar(String fileName, String yml) throws Exception { + File file = tempDir.resolve(fileName).toFile(); + try (JarOutputStream jos = new JarOutputStream(new FileOutputStream(file))) { + jos.putNextEntry(new JarEntry("plugin.yml")); + jos.write(yml.getBytes()); + jos.closeEntry(); + + String className = DummyPlugin.class.getName().replace('.', '/') + ".class"; + jos.putNextEntry(new JarEntry(className)); + try (InputStream is = DummyPlugin.class.getClassLoader().getResourceAsStream(className)) { + assertThat(is).isNotNull(); + byte[] buffer = new byte[1024]; + int len; + while ((len = is.read(buffer)) > 0) { + jos.write(buffer, 0, len); + } + } + jos.closeEntry(); + } + return file; + } + + @Test + void runtimeLoadWiresPresentSoftDependency() throws Exception { + File libJar = createPluginJar("lib-plugin.jar", + "name: LibPlugin\nmain: xin.bbtt.mcbot.plugin.DummyPlugin\nversion: 1.0.0\n"); + File appJar = createPluginJar("app-plugin.jar", + "name: AppPlugin\nmain: xin.bbtt.mcbot.plugin.DummyPlugin\nversion: 1.0.0\n" + + "softdepend: [LibPlugin]\n"); + + // Load the soft dependency first, then load the consumer at runtime. + pluginManager.loadPlugin(libJar); + pluginManager.loadPlugin(appJar); + + PluginClassLoader libLoader = pluginManager.getPluginLoader("LibPlugin"); + PluginClassLoader appLoader = pluginManager.getPluginLoader("AppPlugin"); + assertThat(libLoader).isNotNull(); + assertThat(appLoader).isNotNull(); + + // The present soft dependency must become part of the consumer's classloader chain + // (the single soft dep is wired in as the parent) and be recorded as an effective + // dependency so unload ordering still works. + assertThat(appLoader.getParent()).isSameAs(libLoader); + assertThat(pluginManager.getPluginDependencies().get("AppPlugin")).contains("LibPlugin"); + } + + @Test + void runtimeLoadIgnoresAbsentSoftDependency() throws Exception { + File appJar = createPluginJar("app-plugin.jar", + "name: AppPlugin\nmain: xin.bbtt.mcbot.plugin.DummyPlugin\nversion: 1.0.0\n" + + "softdepend: [MissingPlugin]\n"); + + // A soft dependency that is not present must not block the load and must not be + // recorded as an effective dependency. + pluginManager.loadPlugin(appJar); + + assertThat(pluginManager.getPlugin("AppPlugin")).isNotNull(); + assertThat(pluginManager.getPluginDependencies().get("AppPlugin")).doesNotContain("MissingPlugin"); + } + + @Test + void runtimeLoadRejectsMissingHardDependency() throws Exception { + File appJar = createPluginJar("app-plugin.jar", + "name: AppPlugin\nmain: xin.bbtt.mcbot.plugin.DummyPlugin\nversion: 1.0.0\n" + + "depend: [MissingPlugin]\n"); + + // The startup batch loader refuses plugins with missing hard dependencies, + // so the runtime path must refuse them too instead of silently loading + // without the dependency wired in. + assertThatThrownBy(() -> pluginManager.loadPlugin(appJar)) + .isInstanceOf(IllegalArgumentException.class); + + assertThat(pluginManager.getPlugin("AppPlugin")).isNull(); + assertThat(pluginManager.getPluginLoader("AppPlugin")).isNull(); + } +}