fix: optional plugin dependencies not working correctly (#7094)

#### What type of PR is this?
/kind bug
/area core
/milestone 2.20.x

#### What this PR does / why we need it:
修复可选插件依赖功能无法正常工作的问题

#### Special notes for your reviewer:

使用以下两个插件测试可选依赖:

[测试插件集合.zip](https://github.com/user-attachments/files/17989250/default.zip)

使用以下测试用例进行测试:

测试用例1:plugin-feed 插件提供 RSS 扩展功能

- **前置条件:**  
    安装并启用 `plugin-feed` 插件。
- **操作步骤:**  
    访问 `http://localhost:8090/feed/rss.xml`。
- **期望结果:**  
    返回 `plugin-feed` 提供的 RSS 内容。

---

测试用例 2: plugin-moments 扩展了 plugin-feed 的 RSS 功能(依赖于 plugin-feed)

- **前置条件:**  
    安装并启用 `plugin-feed` 和 `plugin-moments` 插件。
- **操作步骤:**  
    访问 `http://localhost:8090/feed/moments/rss.xml`。
- **期望结果:**  
    返回 `plugin-moments` 提供的 RSS 内容。

---

测试用例 3: plugin-feed 启用时安装 plugin-moments

- **前置条件:**  
    启用 `plugin-feed` 插件。
- **操作步骤:**
    1. 安装 `plugin-moments` 插件。
    2. 访问 `http://localhost:8090/feed/moments/rss.xml`。
- **期望结果:**  
    `plugin-moments` 提供的 RSS 路由可访问,并返回正确内容。

---

测试用例 4: plugin-feed 未启用时安装 plugin-moments

- **前置条件:**  
    未安装或未启用 `plugin-feed` 插件。
- **操作步骤:**
    1. 安装并启用 `plugin-moments` 插件。
    2. 访问 `http://localhost:8090/feed/moments/rss.xml`。
- **期望结果:**
    - `plugin-moments` 的 RSS 路由不可访问,返回 404。
    - `plugin-moments` 的其他功能正常运行。

---

测试用例 5: plugin-moments 启用后安装 plugin-feed

- **前置条件:**  
    已安装并启用 `plugin-moments` 插件。
- **操作步骤:**
    1. 安装并启用 `plugin-feed` 插件。
    2. 访问 `http://localhost:8090/feed/moments/rss.xml`。
- **期望结果:**  
    `plugin-moments` 提供的 RSS 路由可访问,并返回正确内容。

---

测试用例 6: 停止 plugin-feed 后验证 RSS 路由

- **前置条件:**  
    已启用 `plugin-feed` 和 `plugin-moments` 插件。
- **操作步骤:**
    1. 停止 `plugin-feed` 插件。
    2. 访问 `http://localhost:8090/feed/moments/rss.xml`。
- **期望结果:**
    - `plugin-feed` 停止成功。
    - `plugin-moments` 提供的 RSS 路由不可访问,返回 404。

---

测试用例 7: 重启 Halo 后验证可选依赖插件的启动顺序

- **前置条件:**  
    已启用 `plugin-feed` 和 `plugin-moments` 插件。
- **操作步骤:**
    1. 重启 Halo 服务。
    2. 访问 `http://localhost:8090/feed/moments/rss.xml`。
- **期望结果:**
    - `plugin-moments` 提供的 RSS 路由**始终可访问**。

---

测试用例 8: 必选依赖插件验证

- **场景 1: 安装 seo 插件时未安装应用市场**
    
    - **前置条件:**  
        未安装 `app-store-integration` 插件。
    - **操作步骤:**  
        安装 `plugin-seo` 插件。
    - **期望结果:**  
        提示需要先安装 `app-store-integration` 插件。
- **场景 2: 停止应用市场插件时 seo 插件仍启用**
    
    - **前置条件:**  
        已启用 `app-store-integration` 和 `plugin-seo` 插件。
    - **操作步骤:**  
        停止 `app-store-integration` 插件。
    - **期望结果:**  
        提示需要先停止 `plugin-seo` 插件。

#### Does this PR introduce a user-facing change?

```release-note
修复可选插件依赖功能无法正常工作的问题
```
pull/7103/head
guqing 2024-12-04 15:13:10 +08:00 committed by GitHub
parent d06b40cb0c
commit fef06edcd8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 366 additions and 68 deletions

View File

@ -32,7 +32,6 @@ import java.util.function.Predicate;
import java.util.function.Supplier;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.pf4j.PluginDependency;
import org.pf4j.PluginState;
import org.pf4j.PluginWrapper;
import org.pf4j.RuntimeMode;
@ -48,6 +47,7 @@ import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.MetadataUtil;
import run.halo.app.extension.Unstructured;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
@ -60,8 +60,10 @@ import run.halo.app.infra.ConditionStatus;
import run.halo.app.infra.utils.PathUtils;
import run.halo.app.infra.utils.SettingUtils;
import run.halo.app.infra.utils.YamlUnstructuredLoader;
import run.halo.app.plugin.OptionalDependentResolver;
import run.halo.app.plugin.PluginConst;
import run.halo.app.plugin.PluginProperties;
import run.halo.app.plugin.PluginService;
import run.halo.app.plugin.SpringPluginManager;
/**
@ -85,13 +87,16 @@ public class PluginReconciler implements Reconciler<Request> {
private final PluginProperties pluginProperties;
private final PluginService pluginService;
private Clock clock;
public PluginReconciler(ExtensionClient client, SpringPluginManager pluginManager,
PluginProperties pluginProperties) {
PluginProperties pluginProperties, PluginService pluginService) {
this.client = client;
this.pluginManager = pluginManager;
this.pluginProperties = pluginProperties;
this.pluginService = pluginService;
this.clock = Clock.systemUTC();
}
@ -280,18 +285,9 @@ public class PluginReconciler implements Reconciler<Request> {
status.setPhase(Plugin.Phase.STARTING);
// check if the parent plugin is started
var pw = pluginManager.getPlugin(pluginName);
var unstartedDependencies = pw.getDescriptor().getDependencies()
.stream()
.filter(pd -> {
if (pd.isOptional()) {
return false;
}
var parent = pluginManager.getPlugin(pd.getPluginId());
return parent == null || !PluginState.STARTED.equals(parent.getPluginState());
})
.map(PluginDependency::getPluginId)
.toList();
var unstartedDependencies = pluginService.getRequiredDependencies(plugin, pw ->
pw == null || !PluginState.STARTED.equals(pw.getPluginState())
);
var conditions = status.getConditions();
if (!CollectionUtils.isEmpty(unstartedDependencies)) {
removeConditionBy(conditions, ConditionType.READY);
@ -337,9 +333,31 @@ public class PluginReconciler implements Reconciler<Request> {
status.setPhase(Plugin.Phase.STARTED);
log.info("Started plugin {}", pluginName);
requestToReloadPluginsOptionallyDependentOn(pluginName);
return null;
}
void requestToReloadPluginsOptionallyDependentOn(String pluginName) {
var startedPlugins = pluginManager.getStartedPlugins()
.stream()
.map(PluginWrapper::getDescriptor)
.toList();
var resolver = new OptionalDependentResolver(startedPlugins);
var dependents = resolver.getOptionalDependents(pluginName);
for (String dependentName : dependents) {
client.fetch(Plugin.class, dependentName)
.ifPresent(childPlugin -> {
var annotations = MetadataUtil.nullSafeAnnotations(childPlugin);
// loadLocation never be null for started plugins
annotations.put(RELOAD_ANNO,
childPlugin.getStatus().getLoadLocation().toString());
client.update(childPlugin);
});
}
}
private Result disablePlugin(Plugin plugin) {
var pluginName = plugin.getMetadata().getName();
var status = plugin.getStatus();
@ -515,15 +533,9 @@ public class PluginReconciler implements Reconciler<Request> {
}
// check dependencies before loading
var unresolvedParentPlugins = plugin.getSpec().getPluginDependencies().keySet()
.stream()
.filter(dependency -> {
var parentPlugin = pluginManager.getPlugin(dependency);
return parentPlugin == null
|| pluginManager.getUnresolvedPlugins().contains(parentPlugin);
})
.sorted()
.toList();
var unresolvedParentPlugins = pluginService.getRequiredDependencies(plugin,
pw -> pw == null || pluginManager.getUnresolvedPlugins().contains(pw)
);
if (!unresolvedParentPlugins.isEmpty()) {
// requeue if the parent plugin is not loaded yet.
removeConditionBy(conditions, ConditionType.INITIALIZED);

View File

@ -11,6 +11,7 @@ import java.util.stream.Stream;
import lombok.extern.slf4j.Slf4j;
import org.pf4j.PluginRuntimeException;
import org.springframework.beans.factory.support.DefaultBeanNameGenerator;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
import org.springframework.boot.env.PropertySourceLoader;
import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.context.ApplicationContext;
@ -65,13 +66,33 @@ public class DefaultPluginApplicationContextFactory implements PluginApplication
var sw = new StopWatch("CreateApplicationContextFor" + pluginId);
sw.start("Create");
var context = new PluginApplicationContext(pluginId, pluginManager);
var pluginWrapper = pluginManager.getPlugin(pluginId);
var classLoader = pluginWrapper.getPluginClassLoader();
/*
* Manually creating a BeanFactory and setting the plugin's ClassLoader is necessary
* to ensure that conditional annotations (e.g., @ConditionalOnClass) within the plugin
* context can correctly load classes.
* When PluginApplicationContext is created, its constructor initializes an
* AnnotatedBeanDefinitionReader, which in turn creates a ConditionEvaluator.
* ConditionEvaluator is responsible for evaluating conditional annotations such as
* @ConditionalOnClass.
* It relies on the BeanDefinitionRegistry's ClassLoader to load the classes specified in
* the annotations.
* Without explicitly setting the plugin's ClassLoader, the default application
* ClassLoader is used, which fails to load classes from the plugin.
* Therefore, a custom DefaultListableBeanFactory with the plugin ClassLoader is required
* to resolve this issue.
*/
var beanFactory = new DefaultListableBeanFactory();
beanFactory.setBeanClassLoader(classLoader);
var context = new PluginApplicationContext(pluginId, pluginManager, beanFactory);
context.setBeanNameGenerator(DefaultBeanNameGenerator.INSTANCE);
context.registerShutdownHook();
context.setParent(pluginManager.getSharedContext());
var pluginWrapper = pluginManager.getPlugin(pluginId);
var classLoader = pluginWrapper.getPluginClassLoader();
var resourceLoader = new DefaultResourceLoader(classLoader);
context.setResourceLoader(resourceLoader);
sw.stop();
@ -84,7 +105,6 @@ public class DefaultPluginApplicationContextFactory implements PluginApplication
sw.stop();
sw.start("RegisterBeans");
var beanFactory = context.getBeanFactory();
beanFactory.registerSingleton("pluginWrapper", pluginWrapper);
context.registerBean(AggregatedRouterFunction.class);

View File

@ -0,0 +1,56 @@
package run.halo.app.plugin;
import java.util.ArrayList;
import java.util.List;
import org.pf4j.PluginDependency;
import org.pf4j.PluginDescriptor;
import org.pf4j.util.DirectedGraph;
/**
* <p>Pass in a list of started plugin names to resolve dependency relationships, and return a
* list of plugin names that depend on the specified plugin.</p>
* <p>Do not consider the problem of circular dependency, because all the plugins that have been
* started must not have this problem.</p>
*/
public class OptionalDependentResolver {
private final DirectedGraph<String> dependentsGraph;
private final List<PluginDescriptor> plugins;
public OptionalDependentResolver(List<PluginDescriptor> startedPlugins) {
this.plugins = startedPlugins;
this.dependentsGraph = new DirectedGraph<>();
resolve();
}
private void resolve() {
// populate graphs
for (PluginDescriptor plugin : plugins) {
addPlugin(plugin);
}
}
public List<String> getOptionalDependents(String pluginId) {
return new ArrayList<>(dependentsGraph.getNeighbors(pluginId));
}
private void addPlugin(PluginDescriptor descriptor) {
String pluginId = descriptor.getPluginId();
List<PluginDependency> dependencies = descriptor.getDependencies();
if (dependencies.isEmpty()) {
dependentsGraph.addVertex(pluginId);
} else {
boolean edgeAdded = false;
for (PluginDependency dependency : dependencies) {
if (dependency.isOptional()) {
edgeAdded = true;
dependentsGraph.addEdge(dependency.getPluginId(), pluginId);
}
}
// Register the plugin without dependencies, if all of its dependencies are optional.
if (!edgeAdded) {
dependentsGraph.addVertex(pluginId);
}
}
}
}

View File

@ -2,6 +2,7 @@ package run.halo.app.plugin;
import java.util.List;
import java.util.concurrent.locks.StampedLock;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
import org.springframework.context.ApplicationEvent;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.core.ResolvableType;
@ -27,7 +28,9 @@ public class PluginApplicationContext extends AnnotationConfigApplicationContext
private final SpringPluginManager pluginManager;
public PluginApplicationContext(String pluginId, SpringPluginManager pluginManager) {
public PluginApplicationContext(String pluginId, SpringPluginManager pluginManager,
DefaultListableBeanFactory beanFactory) {
super(beanFactory);
this.pluginId = pluginId;
this.pluginManager = pluginManager;
}

View File

@ -1,6 +1,9 @@
package run.halo.app.plugin;
import java.nio.file.Path;
import java.util.List;
import java.util.function.Predicate;
import org.pf4j.PluginWrapper;
import org.springframework.core.io.Resource;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.web.server.ServerWebInputException;
@ -102,4 +105,14 @@ public interface PluginService {
*/
Mono<Plugin> changeState(String pluginName, boolean requestToEnable, boolean wait);
/**
* Gets required dependencies of the given plugin.
*
* @param plugin the plugin to get dependencies from
* {@link Plugin.PluginSpec#getPluginDependencies()}
* @param predicate the predicate to filter by {@link PluginWrapper},such as enabled or disabled
* @return plugin names of required dependencies
*/
List<String> getRequiredDependencies(Plugin plugin,
Predicate<PluginWrapper> predicate);
}

View File

@ -21,10 +21,12 @@ import java.util.HashMap;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.pf4j.DependencyResolver;
import org.pf4j.PluginDependency;
import org.pf4j.PluginDescriptor;
import org.pf4j.PluginWrapper;
import org.reactivestreams.Publisher;
@ -269,12 +271,7 @@ public class PluginServiceImpl implements PluginService, InitializingBean, Dispo
.sort(Comparator.comparing(PluginWrapper::getPluginId))
.flatMapSequential(pluginWrapper -> {
var pluginId = pluginWrapper.getPluginId();
return Mono.<Resource>fromSupplier(
() -> BundleResourceUtils.getJsBundleResource(
pluginManager, pluginId, BundleResourceUtils.JS_BUNDLE
)
)
.filter(Resource::isReadable)
return getBundleResource(pluginId, BundleResourceUtils.JS_BUNDLE)
.flatMapMany(resource -> {
var head = Mono.<DataBuffer>fromSupplier(
() -> dataBufferFactory.wrap(
@ -297,10 +294,7 @@ public class PluginServiceImpl implements PluginService, InitializingBean, Dispo
.flatMapSequential(pluginWrapper -> {
var pluginId = pluginWrapper.getPluginId();
var dataBufferFactory = DefaultDataBufferFactory.sharedInstance;
return Mono.<Resource>fromSupplier(() -> BundleResourceUtils.getJsBundleResource(
pluginManager, pluginId, BundleResourceUtils.CSS_BUNDLE
))
.filter(Resource::isReadable)
return getBundleResource(pluginId, BundleResourceUtils.CSS_BUNDLE)
.flatMapMany(resource -> {
var head = Mono.<DataBuffer>fromSupplier(() -> dataBufferFactory.wrap(
("/* Generated from plugin " + pluginId + " */\n").getBytes()
@ -313,6 +307,13 @@ public class PluginServiceImpl implements PluginService, InitializingBean, Dispo
});
}
private Mono<Resource> getBundleResource(String pluginName, String bundleName) {
return Mono.fromSupplier(
() -> BundleResourceUtils.getJsBundleResource(pluginManager, pluginName, bundleName)
)
.filter(Resource::isReadable);
}
@Override
public Mono<String> generateBundleVersion() {
if (pluginManager.isDevelopment()) {
@ -344,14 +345,9 @@ public class PluginServiceImpl implements PluginService, InitializingBean, Dispo
// preflight check
if (requestToEnable) {
// make sure the dependencies are enabled
var dependencies = plugin.getSpec().getPluginDependencies().keySet();
var notStartedDependencies = dependencies.stream()
.filter(dependency -> {
var pluginWrapper = pluginManager.getPlugin(dependency);
return pluginWrapper == null
|| !Objects.equals(STARTED, pluginWrapper.getPluginState());
})
.toList();
var notStartedDependencies = getRequiredDependencies(plugin,
pw -> pw == null || !Objects.equals(STARTED, pw.getPluginState())
);
if (!CollectionUtils.isEmpty(notStartedDependencies)) {
return Mono.error(
new PluginDependenciesNotEnabledException(notStartedDependencies)
@ -416,6 +412,28 @@ public class PluginServiceImpl implements PluginService, InitializingBean, Dispo
return updatedPlugin;
}
@Override
public List<String> getRequiredDependencies(Plugin plugin,
Predicate<PluginWrapper> predicate) {
return getPluginDependency(plugin)
.stream()
.filter(dependency -> !dependency.isOptional())
.map(PluginDependency::getPluginId)
.filter(dependencyPlugin -> {
var pluginWrapper = pluginManager.getPlugin(dependencyPlugin);
return predicate.test(pluginWrapper);
})
.sorted()
.toList();
}
private static List<PluginDependency> getPluginDependency(Plugin plugin) {
return plugin.getSpec().getPluginDependencies().keySet()
.stream()
.map(PluginDependency::new)
.toList();
}
Mono<Plugin> findPluginManifest(Path path) {
return Mono.fromSupplier(
() -> {

View File

@ -1,20 +1,25 @@
package run.halo.app.plugin.extensionpoint;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.pf4j.ExtensionPoint;
import org.pf4j.PluginManager;
import org.pf4j.PluginWrapper;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.core.annotation.AnnotationAwareOrderComparator;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting.ExtensionPointEnabled;
import run.halo.app.plugin.SpringPlugin;
@Slf4j
@Component
@RequiredArgsConstructor
public class DefaultExtensionGetter implements ExtensionGetter {
@ -31,7 +36,7 @@ public class DefaultExtensionGetter implements ExtensionGetter {
@Override
public <T extends ExtensionPoint> Flux<T> getExtensions(Class<T> extensionPoint) {
return Flux.fromIterable(pluginManager.getExtensions(extensionPoint))
return Flux.fromIterable(lookExtensions(extensionPoint))
.concatWith(
Flux.fromStream(() -> beanFactory.getBeanProvider(extensionPoint).orderedStream())
)
@ -41,8 +46,7 @@ public class DefaultExtensionGetter implements ExtensionGetter {
@Override
public <T extends ExtensionPoint> List<T> getExtensionList(Class<T> extensionPoint) {
var extensions = new LinkedList<T>();
Optional.ofNullable(pluginManager.getExtensions(extensionPoint))
.ifPresent(extensions::addAll);
extensions.addAll(lookExtensions(extensionPoint));
extensions.addAll(beanFactory.getBeanProvider(extensionPoint).orderedStream().toList());
extensions.sort(new AnnotationAwareOrderComparator());
return extensions;
@ -96,4 +100,29 @@ public class DefaultExtensionGetter implements ExtensionGetter {
return extensionPointDefinitionGetter.getByClassName(extensionPoint.getName());
}
@NonNull
protected <T> List<T> lookExtensions(Class<T> type) {
List<T> beans = new ArrayList<>();
// avoid concurrent modification
var startedPlugins = List.copyOf(pluginManager.getStartedPlugins());
for (PluginWrapper startedPlugin : startedPlugins) {
if (startedPlugin.getPlugin() instanceof SpringPlugin springPlugin) {
var pluginApplicationContext = springPlugin.getApplicationContext();
if (pluginApplicationContext == null) {
continue;
}
try {
pluginApplicationContext.getBeansOfType(type)
.forEach((name, bean) -> beans.add(bean));
} catch (Throwable e) {
// Ignore
log.error("Error while looking for extensions of type {}", type, e);
}
} else {
var extensions = pluginManager.getExtensions(type, startedPlugin.getPluginId());
beans.addAll(extensions);
}
}
return beans;
}
}

View File

@ -48,7 +48,6 @@ import org.springframework.core.io.DefaultResourceLoader;
import run.halo.app.core.extension.Plugin;
import run.halo.app.core.extension.ReverseProxy;
import run.halo.app.core.extension.Setting;
import run.halo.app.core.reconciler.PluginReconciler;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.Metadata;
@ -58,6 +57,7 @@ import run.halo.app.extension.controller.RequeueException;
import run.halo.app.infra.Condition;
import run.halo.app.infra.ConditionStatus;
import run.halo.app.plugin.PluginProperties;
import run.halo.app.plugin.PluginService;
import run.halo.app.plugin.SpringPluginManager;
/**
@ -78,6 +78,9 @@ class PluginReconcilerTest {
@Mock
PluginProperties pluginProperties;
@Mock
private PluginService pluginService;
@InjectMocks
PluginReconciler reconciler;
@ -111,6 +114,12 @@ class PluginReconcilerTest {
@TempDir
Path tempPath;
@BeforeEach
void setUp() {
lenient().when(pluginService.getRequiredDependencies(any(), any()))
.thenReturn(List.of());
}
@Test
void shouldNotStartPluginWithDevModeInNonDevEnv() {
var fakePlugin = createPlugin(name, plugin -> {
@ -277,7 +286,7 @@ class PluginReconcilerTest {
.thenReturn(mockPluginWrapperForSetting())
.thenReturn(mockPluginWrapperForStaticResources())
// before starting
.thenReturn(mockPluginWrapper(PluginState.STOPPED))
.thenReturn(mockPluginWrapper(PluginState.STARTED))
// sync plugin state
.thenReturn(mockPluginWrapper(PluginState.STARTED));
when(pluginManager.startPlugin(name)).thenReturn(PluginState.STARTED);
@ -308,7 +317,7 @@ class PluginReconcilerTest {
verify(pluginManager).startPlugin(name);
verify(pluginManager).loadPlugin(loadLocation);
verify(pluginManager, times(5)).getPlugin(name);
verify(pluginManager, times(4)).getPlugin(name);
verify(client).update(fakePlugin);
verify(client).fetch(Setting.class, settingName);
verify(client).create(any(Setting.class));

View File

@ -0,0 +1,126 @@
package run.halo.app.plugin;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.pf4j.PluginDependency;
import org.pf4j.PluginDescriptor;
/**
* Tests for {@link OptionalDependentResolver}.
*
* @author guqing
* @since 2.20.11
*/
class OptionalDependentResolverTest {
@Test
void testNoPlugins() {
OptionalDependentResolver resolver = new OptionalDependentResolver(List.of());
assertTrue(resolver.getOptionalDependents("nonexistent").isEmpty(),
"No dependents expected for non-existent plugin");
}
@Test
void testSinglePluginNoDependencies() {
var pluginA = createpluginDescriptor("A", List.of());
OptionalDependentResolver resolver = new OptionalDependentResolver(List.of(pluginA));
assertTrue(resolver.getOptionalDependents("A").isEmpty(), "A has no dependents");
}
@Test
void testSingleOptionalDependency() {
var pluginA = createpluginDescriptor("A", List.of(new PluginDependency("B?")));
var pluginB = createpluginDescriptor("B", List.of());
OptionalDependentResolver resolver =
new OptionalDependentResolver(List.of(pluginA, pluginB));
assertEquals(List.of("A"), resolver.getOptionalDependents("B"),
"B should have A as dependent");
assertTrue(resolver.getOptionalDependents("A").isEmpty(), "A has no dependents");
}
@Test
void testMultipleOptionalDependencies() {
var pluginA = createpluginDescriptor("A", List.of(
new PluginDependency("B?"),
new PluginDependency("C?"))
);
var pluginB = createpluginDescriptor("B", List.of());
var pluginC = createpluginDescriptor("C", List.of());
OptionalDependentResolver resolver =
new OptionalDependentResolver(List.of(pluginA, pluginB, pluginC));
assertEquals(List.of("A"), resolver.getOptionalDependents("B"),
"B should have A as dependent");
assertEquals(List.of("A"), resolver.getOptionalDependents("C"),
"C should have A as dependent");
assertTrue(resolver.getOptionalDependents("A").isEmpty(), "A has no dependents");
}
@Test
void testNestedDependencies() {
var pluginA = createpluginDescriptor("A", List.of(
new PluginDependency("B?")
));
var pluginB = createpluginDescriptor("B", List.of(
new PluginDependency("C?")
));
var pluginC = createpluginDescriptor("C", List.of());
OptionalDependentResolver resolver =
new OptionalDependentResolver(List.of(pluginA, pluginB, pluginC));
assertEquals(List.of("B"), resolver.getOptionalDependents("C"),
"C should have B as dependent");
assertEquals(List.of("A"), resolver.getOptionalDependents("B"),
"B should have A as dependent");
assertTrue(resolver.getOptionalDependents("A").isEmpty(), "A has no dependents");
}
@Test
void testCircularDependencies() {
var pluginA = createpluginDescriptor("A", List.of(
new PluginDependency("B?")
));
var pluginB = createpluginDescriptor("B", List.of(
new PluginDependency("A?")
));
OptionalDependentResolver resolver =
new OptionalDependentResolver(List.of(pluginA, pluginB));
assertEquals(List.of("B"), resolver.getOptionalDependents("A"),
"A should have B as dependent");
assertEquals(List.of("A"), resolver.getOptionalDependents("B"),
"B should have A as dependent");
}
@Test
void testNonOptionalDependencies() {
var pluginA = createpluginDescriptor("A", List.of(
new PluginDependency("B")
));
var pluginB = createpluginDescriptor("B", List.of());
OptionalDependentResolver resolver =
new OptionalDependentResolver(List.of(pluginA, pluginB));
assertTrue(resolver.getOptionalDependents("B").isEmpty(), "B should have no dependents");
assertTrue(resolver.getOptionalDependents("A").isEmpty(), "A should have no dependents");
}
PluginDescriptor createpluginDescriptor(String pluginName,
List<PluginDependency> dependencies) {
var descriptor = mock(PluginDescriptor.class);
lenient().when(descriptor.getPluginId()).thenReturn(pluginName);
lenient().when(descriptor.getDependencies()).thenReturn(dependencies);
return descriptor;
}
}

View File

@ -3,7 +3,9 @@ package run.halo.app.plugin.extensionpoint;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.when;
import static run.halo.app.infra.SystemSetting.ExtensionPointEnabled.GROUP;
@ -82,10 +84,12 @@ class DefaultExtensionGetterTest {
when(beanFactory.getBeanProvider(FakeExtensionPoint.class)).thenReturn(objectProvider);
var extensionImpl = new FakeExtensionPointImpl();
when(pluginManager.getExtensions(FakeExtensionPoint.class))
.thenReturn(List.of(extensionImpl));
getter.getEnabledExtensions(FakeExtensionPoint.class)
var spyGetter = spy(getter);
doReturn(List.of(extensionImpl)).when(spyGetter)
.lookExtensions(eq(FakeExtensionPoint.class));
spyGetter.getEnabledExtensions(FakeExtensionPoint.class)
.as(StepVerifier::create)
.expectNext(extensionImpl)
.verifyComplete();
@ -110,10 +114,11 @@ class DefaultExtensionGetterTest {
.thenReturn(Stream.of(extensionDefaultImpl));
when(beanFactory.getBeanProvider(FakeExtensionPoint.class)).thenReturn(objectProvider);
when(pluginManager.getExtensions(FakeExtensionPoint.class))
.thenReturn(List.of());
var spyGetter = spy(getter);
doReturn(List.of()).when(spyGetter)
.lookExtensions(eq(FakeExtensionPoint.class));
getter.getEnabledExtensions(FakeExtensionPoint.class)
spyGetter.getEnabledExtensions(FakeExtensionPoint.class)
.as(StepVerifier::create)
.expectNext(extensionDefaultImpl)
.verifyComplete();
@ -159,10 +164,12 @@ class DefaultExtensionGetterTest {
var extensionImpl = new FakeExtensionPointImpl();
var anotherExtensionImpl = new FakeExtensionPoint() {
};
when(pluginManager.getExtensions(FakeExtensionPoint.class))
.thenReturn(List.of(extensionImpl, anotherExtensionImpl));
getter.getEnabledExtensions(FakeExtensionPoint.class)
var spyGetter = spy(getter);
doReturn(List.of(extensionImpl, anotherExtensionImpl)).when(spyGetter)
.lookExtensions(eq(FakeExtensionPoint.class));
spyGetter.getEnabledExtensions(FakeExtensionPoint.class)
.as(StepVerifier::create)
// should keep the order of enabled extensions
.expectNext(extensionDefaultImpl)
@ -194,10 +201,12 @@ class DefaultExtensionGetterTest {
var extensionImpl = new FakeExtensionPointImpl();
var anotherExtensionImpl = new FakeExtensionPoint() {
};
when(pluginManager.getExtensions(FakeExtensionPoint.class))
.thenReturn(List.of(extensionImpl, anotherExtensionImpl));
getter.getEnabledExtensions(FakeExtensionPoint.class)
var spyGetter = spy(getter);
doReturn(List.of(extensionImpl, anotherExtensionImpl)).when(spyGetter)
.lookExtensions(eq(FakeExtensionPoint.class));
spyGetter.getEnabledExtensions(FakeExtensionPoint.class)
.as(StepVerifier::create)
// should keep the order according to @Order annotation
// order is 1
@ -213,13 +222,16 @@ class DefaultExtensionGetterTest {
void shouldGetExtensionsFromPluginManagerAndApplicationContext() {
var extensionFromPlugin = new FakeExtensionPointDefaultImpl();
var extensionFromAppContext = new FakeExtensionPointImpl();
when(pluginManager.getExtensions(FakeExtensionPoint.class))
.thenReturn(List.of(extensionFromPlugin));
var spyGetter = spy(getter);
doReturn(List.of(extensionFromPlugin)).when(spyGetter)
.lookExtensions(eq(FakeExtensionPoint.class));
when(beanFactory.getBeanProvider(FakeExtensionPoint.class))
.thenReturn(extensionPointObjectProvider);
when(extensionPointObjectProvider.orderedStream())
.thenReturn(Stream.of(extensionFromAppContext));
var extensions = getter.getExtensionList(FakeExtensionPoint.class);
var extensions = spyGetter.getExtensionList(FakeExtensionPoint.class);
assertEquals(List.of(extensionFromAppContext, extensionFromPlugin), extensions);
}