Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 11 additions & 16 deletions src/main/java/io/github/ktestify/plugin/PluginRegistry.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,7 @@
import java.io.File;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.ServiceLoader;
import java.util.*;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand Down Expand Up @@ -93,7 +89,7 @@ private PluginRegistry(List<KtestifyPlugin> plugins) {
public static PluginRegistry load(PluginContext ctx) {
List<KtestifyPlugin> all = new ArrayList<>();

LOG.info("╔══ KTestify Plugin System ═════════════════════════════════════");
LOG.info("Loading plugins...");

// Phase 1 β€” classpath / fat-jar plugins
loadFromClasspath(ctx, all);
Expand All @@ -103,14 +99,13 @@ public static PluginRegistry load(PluginContext ctx) {
loadFromDirectory(pluginsDir, ctx, all);

if (all.isEmpty()) {
LOG.info("β•‘ No plugins loaded.");
LOG.info("No plugins loaded.");
} else {
LOG.info(
"β•‘ {} plugin(s) active: [{}]",
"Plugin system ready: {} plugin(s) active [{}]",
all.size(),
all.stream().map(p -> p.getId() + "@" + p.getVersion()).collect(Collectors.joining(", ")));
}
LOG.info("β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•");

return new PluginRegistry(all);
}
Expand Down Expand Up @@ -174,7 +169,7 @@ private static void loadFromClasspath(PluginContext ctx, List<KtestifyPlugin> ta
int before = target.size();
for (KtestifyPlugin plugin : loader) {
LOG.info(
"β•‘ [classpath] Discovered plugin: {} v{} β€” author: {} <{}>",
"[classpath] Plugin discovered: {} v{} (author: {} <{}>)",
plugin.getId(),
plugin.getVersion(),
plugin.getAuthorName(),
Expand All @@ -192,23 +187,23 @@ private static void loadFromClasspath(PluginContext ctx, List<KtestifyPlugin> ta
*/
private static void loadFromDirectory(String dirPath, PluginContext ctx, List<KtestifyPlugin> target) {
if (dirPath == null || dirPath.isBlank()) {
LOG.debug("Phase 2 (external): plugins dir not configured β€” skipping.");
LOG.debug("Phase 2 (external): plugins dir not configured.");
return;
}

File dir = new File(dirPath);
if (!dir.exists() || !dir.isDirectory()) {
LOG.debug("Phase 2 (external): directory '{}' does not exist β€” no external plugins loaded.", dirPath);
LOG.debug("Phase 2 (external): directory '{}' does not exist.", dirPath);
return;
}

File[] jars = dir.listFiles(f -> f.isFile() && f.getName().endsWith(".jar"));
if (jars == null || jars.length == 0) {
LOG.debug("Phase 2 (external): no *.jar files found in '{}' β€” skipping.", dirPath);
LOG.debug("Phase 2 (external): no *.jar files found in '{}'", dirPath);
return;
}

LOG.info("β•‘ [external] Scanning '{}' β€” {} JAR(s) found.", dirPath, jars.length);
LOG.info("[external] Scanning '{}' β€” {} JAR(s) found.", dirPath, jars.length);

// Build a single URLClassLoader for all external jars (parent = current context CL)
URL[] urls = Arrays.stream(jars)
Expand All @@ -228,7 +223,7 @@ private static void loadFromDirectory(String dirPath, PluginContext ctx, List<Kt
int before = target.size();
for (KtestifyPlugin plugin : loader) {
LOG.info(
"β•‘ [external] Discovered plugin: {} v{} β€” author: {} <{}> (from '{}')",
"[external] Plugin discovered: {} v{} (author: {} <{}>, from '{}')",
plugin.getId(),
plugin.getVersion(),
plugin.getAuthorName(),
Expand All @@ -245,7 +240,7 @@ private static void loadFromDirectory(String dirPath, PluginContext ctx, List<Kt
private static void initPlugin(KtestifyPlugin plugin, PluginContext ctx) {
try {
plugin.initialize(ctx);
LOG.info("β•‘ Plugin '{}' initialized successfully.", plugin.getId());
LOG.info("Plugin '{}' initialized.", plugin.getId());
} catch (PluginException e) {
throw e; // already wrapped
} catch (Exception e) {
Expand Down
107 changes: 107 additions & 0 deletions src/main/java/io/github/ktestify/plugin/PluginVersionResolver.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
* Copyright 2026 Nil MALHOMME (malhomme.nil+oss@icloud.com)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.github.ktestify.plugin;

import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Resolves a plugin's build version from a Maven-filtered {@code plugin-version.properties} file on the classpath.
*
* <p>Each plugin ships a {@code plugin-version.properties} file in its own package directory (e.g.
* {@code /io/github/ktestify/azureblob/plugin-version.properties}). The file contains a single property:
*
* <pre>
* plugin.version=${project.version}
* </pre>
*
* Maven substitutes {@code ${project.version}} at build time. This approach works in all deployment contexts:
*
* <ul>
* <li>Standalone plugin JARs (external plugins loaded via {@code URLClassLoader})
* <li>The shaded fat JAR (each plugin's file is at a unique package-relative path, so no collision)
* <li>IDE runs (falls back to {@code "dev"} when the file is missing or unfiltered)
* </ul>
*
* <p>Plugins should call {@link #resolve(Class, String)} once at class-loading time and store the result in a
* {@code static final} field:
*
* <pre>
* private static final String VERSION = PluginVersionResolver.resolve(MyPlugin.class, "dev");
* </pre>
*
* @since 1.1.1
*/
public final class PluginVersionResolver {

private static final Logger LOG = LoggerFactory.getLogger(PluginVersionResolver.class);

/** Resource file name, looked up relative to the plugin class's package. */
private static final String RESOURCE_NAME = "plugin-version.properties";

/** Property key inside the {@code plugin-version.properties} file. */
private static final String PROPERTY_KEY = "plugin.version";

private PluginVersionResolver() {}

/**
* Resolves the plugin version from the Maven-filtered {@code plugin-version.properties} file located in the same
* package as the given plugin class.
*
* <p>If the file is missing, empty, or contains an unfiltered {@code ${project.version}} placeholder (e.g. when
* running from an IDE without Maven resource filtering), the fallback value is returned.
*
* @param pluginClass the plugin's main class, used to locate the resource in the correct package
* @param fallback the version to return if the resource cannot be resolved
* @return the resolved version string, or the fallback
*/
public static String resolve(Class<?> pluginClass, String fallback) {
try (InputStream is = pluginClass.getResourceAsStream(RESOURCE_NAME)) {
if (is == null) {
LOG.debug(
"No '{}' found next to {}, using fallback version '{}'.",
RESOURCE_NAME,
pluginClass.getName(),
fallback);
return fallback;
}
Properties props = new Properties();
props.load(is);
String version = props.getProperty(PROPERTY_KEY);
if (version == null || version.isBlank() || version.startsWith("${")) {
LOG.debug(
"Property '{}' in '{}' is unfiltered or blank for {}, using fallback version '{}'.",
PROPERTY_KEY,
RESOURCE_NAME,
pluginClass.getName(),
fallback);
return fallback;
}
return version;
} catch (IOException e) {
LOG.warn(
"Failed to read '{}' for {}, using fallback version '{}': {}",
RESOURCE_NAME,
pluginClass.getName(),
fallback,
e.getMessage());
return fallback;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* Copyright 2026 Nil MALHOMME (malhomme.nil+oss@icloud.com)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.github.ktestify.plugin;

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

/**
* Unit tests for {@link PluginVersionResolver}.
*
* <p>Verifies fallback behaviour when the {@code plugin-version.properties} resource is missing or unfiltered, which is
* the expected state in IDE runs and unit test contexts.
*/
@DisplayName("PluginVersionResolver")
class PluginVersionResolverTest {

@Test
@DisplayName("resolve() returns fallback when no plugin-version.properties is found next to the class")
void returnsFallbackWhenResourceMissing() {
// This test class has no plugin-version.properties next to it
String version = PluginVersionResolver.resolve(PluginVersionResolverTest.class, "dev");
assertEquals("dev", version);
}

@Test
@DisplayName("resolve() returns fallback when fallback is a real version string")
void returnsFallbackVersionString() {
String version = PluginVersionResolver.resolve(PluginVersionResolverTest.class, "1.2.3");
assertEquals("1.2.3", version);
}

@Test
@DisplayName("resolve() returns fallback when fallback is blank")
void returnsBlankFallback() {
String version = PluginVersionResolver.resolve(PluginVersionResolverTest.class, "");
assertEquals("", version);
}

@Test
@DisplayName("resolve() on PluginVersionResolver itself returns fallback (no resource next to it)")
void resolveOnItselfReturnsFallback() {
String version = PluginVersionResolver.resolve(PluginVersionResolver.class, "test");
assertEquals("test", version);
}
}
Loading