mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-04 02:20:19 +01:00
refactor(core): parallel, timeout-safe external dependency probes with version gating + tests (#4640)
# Description of Changes
**What was changed**
- Rewrote `ExternalAppDepConfig` to:
- Run dependency probes in parallel with per-call timeouts to avoid
startup hangs on broken PATHs.
- Support both Unix (`command -v`) and Windows (`where`) lookups in a
single codepath with a fallback `--version` probe.
- Centralize version extraction via a regex (`(\d+(?:\.\d+){0,2})`) and
add a small `Version` comparator (major.minor.patch).
- Enforce a minimum WeasyPrint version (`>= 58.0`), disabling affected
group(s) if the requirement is not met.
- Improve Python/OpenCV handling:
- Resolve interpreter (`python3` → `python`) and check `import cv2`;
disable OpenCV group if unavailable.
- Disable both Python and OpenCV groups when no interpreter is present.
- Keep the command→group mapping immutable and include
runtime-configured paths for WeasyPrint/Unoconvert.
- Improve feature name formatting derived from endpoints (e.g.,
`pdf-to-html` → `PDF To Html`, `img-extract` → `Image Extract`).
- Ensure thread pool shutdown and emit a consolidated disabled-endpoints
summary at the end of checks.
- Added `ExternalAppDepConfigTest` (JUnit + Mockito) to cover:
- Mapping includes runtime paths and core commands.
- Endpoint-to-feature formatting and capitalization rules (`pdf` →
`PDF`, mixed case normalization).
- WeasyPrint command detection (`/custom/weasyprint`, name contains).
- Version comparison edge cases (e.g., `58`, `57.9.2`, `58.beta`).
**Why the change was made**
- Prevents startup stalls caused by long-running or broken shell
lookups.
- Unifies platform-specific logic and de-duplicates probing/formatting
across the codebase.
- Introduces explicit version gating for WeasyPrint to ensure feature
reliability and predictable behavior.
- Makes dependency handling more observable (structured logs) and
maintainable (immutable mappings, focused helpers).
- Improves resilience of Python/OpenCV-dependent features across diverse
environments.
---
## Checklist
### General
- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ExternalAppDepConfigTest {
|
||||
|
||||
@Mock private EndpointConfiguration endpointConfiguration;
|
||||
@Mock private RuntimePathConfig runtimePathConfig;
|
||||
|
||||
private ExternalAppDepConfig config;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
when(runtimePathConfig.getWeasyPrintPath()).thenReturn("/custom/weasyprint");
|
||||
when(runtimePathConfig.getUnoConvertPath()).thenReturn("/custom/unoconvert");
|
||||
lenient()
|
||||
.when(endpointConfiguration.getEndpointsForGroup(anyString()))
|
||||
.thenReturn(Set.of());
|
||||
|
||||
config = new ExternalAppDepConfig(endpointConfiguration, runtimePathConfig);
|
||||
}
|
||||
|
||||
@Test
|
||||
void commandToGroupMappingIncludesRuntimePaths() throws Exception {
|
||||
Map<String, List<String>> mapping = getCommandToGroupMapping();
|
||||
|
||||
assertEquals(List.of("Weasyprint"), mapping.get("/custom/weasyprint"));
|
||||
assertEquals(List.of("Unoconvert"), mapping.get("/custom/unoconvert"));
|
||||
assertEquals(List.of("Ghostscript"), mapping.get("gs"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAffectedFeaturesFormatsEndpoints() throws Exception {
|
||||
Set<String> endpoints = new LinkedHashSet<>(List.of("pdf-to-html", "img-extract"));
|
||||
when(endpointConfiguration.getEndpointsForGroup("Ghostscript")).thenReturn(endpoints);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> features =
|
||||
(List<String>) invokePrivateMethod(config, "getAffectedFeatures", "Ghostscript");
|
||||
|
||||
assertEquals(List.of("PDF To Html", "Image Extract"), features);
|
||||
}
|
||||
|
||||
@Test
|
||||
void formatEndpointAsFeatureConvertsNames() throws Exception {
|
||||
String formatted =
|
||||
(String) invokePrivateMethod(config, "formatEndpointAsFeature", "pdf-img-extract");
|
||||
|
||||
assertEquals("PDF Image Extract", formatted);
|
||||
}
|
||||
|
||||
@Test
|
||||
void capitalizeWordHandlesSpecialCases() throws Exception {
|
||||
String pdf = (String) invokePrivateMethod(config, "capitalizeWord", "pdf");
|
||||
String mixed = (String) invokePrivateMethod(config, "capitalizeWord", "tEsT");
|
||||
String empty = (String) invokePrivateMethod(config, "capitalizeWord", "");
|
||||
|
||||
assertEquals("PDF", pdf);
|
||||
assertEquals("Test", mixed);
|
||||
assertEquals("", empty);
|
||||
}
|
||||
|
||||
@Test
|
||||
void isWeasyprintMatchesConfiguredCommands() throws Exception {
|
||||
boolean directMatch =
|
||||
(boolean) invokePrivateMethod(config, "isWeasyprint", "/custom/weasyprint");
|
||||
boolean nameContains =
|
||||
(boolean) invokePrivateMethod(config, "isWeasyprint", "/usr/bin/weasyprint-cli");
|
||||
boolean differentCommand = (boolean) invokePrivateMethod(config, "isWeasyprint", "qpdf");
|
||||
|
||||
assertTrue(directMatch);
|
||||
assertTrue(nameContains);
|
||||
assertFalse(differentCommand);
|
||||
}
|
||||
|
||||
@Test
|
||||
void versionComparisonHandlesDifferentFormats() {
|
||||
ExternalAppDepConfig.Version required = new ExternalAppDepConfig.Version("58");
|
||||
ExternalAppDepConfig.Version installed = new ExternalAppDepConfig.Version("57.9.2");
|
||||
ExternalAppDepConfig.Version beta = new ExternalAppDepConfig.Version("58.beta");
|
||||
|
||||
assertTrue(installed.compareTo(required) < 0);
|
||||
assertEquals(0, beta.compareTo(required));
|
||||
assertEquals("58.0.0", beta.toString());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, List<String>> getCommandToGroupMapping() throws Exception {
|
||||
Field field = ExternalAppDepConfig.class.getDeclaredField("commandToGroupMapping");
|
||||
field.setAccessible(true);
|
||||
return (Map<String, List<String>>) field.get(config);
|
||||
}
|
||||
|
||||
private Object invokePrivateMethod(Object target, String methodName, Object... args)
|
||||
throws Exception {
|
||||
Method method = findMatchingMethod(methodName, args);
|
||||
method.setAccessible(true);
|
||||
return method.invoke(target, args);
|
||||
}
|
||||
|
||||
private Method findMatchingMethod(String methodName, Object[] args)
|
||||
throws NoSuchMethodException {
|
||||
Method[] methods = ExternalAppDepConfig.class.getDeclaredMethods();
|
||||
for (Method candidate : methods) {
|
||||
if (!candidate.getName().equals(methodName)
|
||||
|| candidate.getParameterCount() != args.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Class<?>[] parameterTypes = candidate.getParameterTypes();
|
||||
boolean matches = true;
|
||||
for (int i = 0; i < parameterTypes.length; i++) {
|
||||
if (!isParameterCompatible(parameterTypes[i], args[i])) {
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matches) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new NoSuchMethodException(
|
||||
"No matching method found for " + methodName + " with provided arguments");
|
||||
}
|
||||
|
||||
private boolean isParameterCompatible(Class<?> parameterType, Object arg) {
|
||||
if (arg == null) {
|
||||
return !parameterType.isPrimitive();
|
||||
}
|
||||
|
||||
Class<?> argumentClass = arg.getClass();
|
||||
if (parameterType.isPrimitive()) {
|
||||
return getWrapperType(parameterType).isAssignableFrom(argumentClass);
|
||||
}
|
||||
|
||||
return parameterType.isAssignableFrom(argumentClass);
|
||||
}
|
||||
|
||||
private Class<?> getWrapperType(Class<?> primitiveType) {
|
||||
if (primitiveType == boolean.class) {
|
||||
return Boolean.class;
|
||||
} else if (primitiveType == byte.class) {
|
||||
return Byte.class;
|
||||
} else if (primitiveType == short.class) {
|
||||
return Short.class;
|
||||
} else if (primitiveType == int.class) {
|
||||
return Integer.class;
|
||||
} else if (primitiveType == long.class) {
|
||||
return Long.class;
|
||||
} else if (primitiveType == float.class) {
|
||||
return Float.class;
|
||||
} else if (primitiveType == double.class) {
|
||||
return Double.class;
|
||||
} else if (primitiveType == char.class) {
|
||||
return Character.class;
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("Type is not primitive: " + primitiveType);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user