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();
+ }
+}