You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sling.apache.org by js...@apache.org on 2026/08/15 13:32:52 UTC
(sling-org-apache-sling-servlets-resolver) 28/28: SLING-13292 - Support ranking of bundles contributing bundled scripts (#73)
This is an automated email from the ASF dual-hosted git repository.
jsedding pushed a commit to branch resolver-2.x-backports
in repository https://gitbox.apache.org/repos/asf/sling-org-apache-sling-servlets-resolver.git
commit 877209f08e8343b3fb16cd1c5ab2bbd9912919c1
Author: Julian Sedding <js...@apache.org>
AuthorDate: Fri Aug 14 07:57:42 2026 +0200
SLING-13292 - Support ranking of bundles contributing bundled scripts (#73)
* SLING-13292 - Support ranking of bundles contributing bundled scripts
Co-authored-by: Claude Opus 4.8 <no...@anthropic.com>
(cherry picked from commit ce29b5717011ba6299bdc7d30212e67a69a42289)
---
.../internal/bundle/BundledScriptTracker.java | 49 ++-
.../internal/bundle/BundledScriptTrackerTest.java | 465 ++++++++++++++++++++-
.../bundle/BundledScriptsRankingHeaderTest.java | 84 ++++
3 files changed, 593 insertions(+), 5 deletions(-)
diff --git a/src/main/java/org/apache/sling/servlets/resolver/internal/bundle/BundledScriptTracker.java b/src/main/java/org/apache/sling/servlets/resolver/internal/bundle/BundledScriptTracker.java
index 2915722..68a7d36 100644
--- a/src/main/java/org/apache/sling/servlets/resolver/internal/bundle/BundledScriptTracker.java
+++ b/src/main/java/org/apache/sling/servlets/resolver/internal/bundle/BundledScriptTracker.java
@@ -108,6 +108,14 @@ public class BundledScriptTracker implements BundleTrackerCustomizer<List<Servic
public static final String AT_SCRIPT_EXTENSION = "scriptExtension";
public static final String AT_EXTENDS = "extends";
+ /**
+ * Optional manifest header a bundle may declare to influence the OSGi {@code service.ranking} property of
+ * every bundled script/servlet it contributes. Its value must be an integer; an absent or non-integer value
+ * maintains the previous behaviour, where the services are registered without a {@code service.ranking}
+ * property.
+ */
+ static final String HEADER_SCRIPTS_RANKING = "Sling-Bundled-Scripts-Ranking";
+
@Reference
private BundledRenderUnitFinder bundledRenderUnitFinder;
@@ -183,11 +191,17 @@ public class BundledScriptTracker implements BundleTrackerCustomizer<List<Servic
Set<TypeProvider> requiresChain = collectRequiresChain(bundleWiring, cache);
if (!capabilities.isEmpty()) {
Instant registerStart = Instant.now();
+ final Integer scriptsRanking = getBundledScriptsRanking(bundle);
Set<BundledRenderUnitCapability> bundledRenderUnitCapabilities = new HashSet<>(cache.values());
bundledRenderUnitCapabilities = reduce(bundledRenderUnitCapabilities);
List<ServiceRegistration<Servlet>> serviceRegistrations = bundledRenderUnitCapabilities.stream()
.flatMap(bundledRenderUnitCapability -> registerServicesWithinBundle(
- bundle, bundleWiring, cache, requiresChain, bundledRenderUnitCapability))
+ bundle,
+ bundleWiring,
+ cache,
+ requiresChain,
+ bundledRenderUnitCapability,
+ scriptsRanking))
.collect(Collectors.toList());
refreshDispatcher(serviceRegistrations);
long duration = Duration.between(registerStart, Instant.now()).toMillis();
@@ -206,13 +220,42 @@ public class BundledScriptTracker implements BundleTrackerCustomizer<List<Servic
}
}
+ /**
+ * Reads the optional {@link #HEADER_SCRIPTS_RANKING} manifest header of the given bundle.
+ *
+ * @param bundle the bundle contributing bundled scripts
+ * @return the header value as an {@link Integer}, or {@code null} if the header is absent or its value cannot
+ * be coerced to an {@link Integer}
+ */
+ static @Nullable Integer getBundledScriptsRanking(final Bundle bundle) {
+ final String value = bundle.getHeaders().get(HEADER_SCRIPTS_RANKING);
+ if (value == null) {
+ return null;
+ }
+ try {
+ return Integer.valueOf(value.trim());
+ } catch (final NumberFormatException e) {
+ LOGGER.warn(
+ "Ignoring manifest header {} of bundle {}: value '{}' cannot be coerced to an Integer.",
+ HEADER_SCRIPTS_RANKING,
+ bundle.getSymbolicName(),
+ value);
+ return null;
+ }
+ }
+
Stream<? extends ServiceRegistration<Servlet>> registerServicesWithinBundle(
Bundle bundle,
BundleWiring bundleWiring,
Map<BundleCapability, BundledRenderUnitCapability> cache,
Set<TypeProvider> requiresChain,
- BundledRenderUnitCapability bundledRenderUnitCapability) {
+ BundledRenderUnitCapability bundledRenderUnitCapability,
+ @Nullable Integer serviceRanking) {
Hashtable<String, Object> properties = new Hashtable<>();
+ if (serviceRanking != null) {
+ properties.put(Constants.SERVICE_RANKING, serviceRanking);
+ }
+
BundledRenderUnit executable = null;
TypeProvider baseTypeProvider = new TypeProviderImpl(bundledRenderUnitCapability, bundle);
LinkedHashSet<TypeProvider> inheritanceChain = new LinkedHashSet<>();
@@ -616,7 +659,7 @@ public class BundledScriptTracker implements BundleTrackerCustomizer<List<Servic
return Collections.unmodifiableSet(registeredBundles);
}
- private class DispatcherServlet extends GenericServlet {
+ class DispatcherServlet extends GenericServlet {
private static final long serialVersionUID = -1917128676758775458L;
private final Set<String> resourceType;
diff --git a/src/test/java/org/apache/sling/servlets/resolver/internal/bundle/BundledScriptTrackerTest.java b/src/test/java/org/apache/sling/servlets/resolver/internal/bundle/BundledScriptTrackerTest.java
index 6e3216c..f8d3a52 100644
--- a/src/test/java/org/apache/sling/servlets/resolver/internal/bundle/BundledScriptTrackerTest.java
+++ b/src/test/java/org/apache/sling/servlets/resolver/internal/bundle/BundledScriptTrackerTest.java
@@ -19,25 +19,93 @@
package org.apache.sling.servlets.resolver.internal.bundle;
import javax.servlet.Servlet;
+import javax.servlet.ServletContext;
+import javax.servlet.http.HttpServletResponse;
+import java.lang.reflect.Proxy;
import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Dictionary;
+import java.util.HashMap;
+import java.util.Hashtable;
import java.util.List;
+import java.util.Map;
+import org.apache.sling.api.SlingHttpServletRequest;
+import org.apache.sling.api.SlingHttpServletResponse;
+import org.apache.sling.api.resource.ResourceResolverFactory;
+import org.apache.sling.api.servlets.ServletResolverConstants;
+import org.apache.sling.scripting.spi.bundle.BundledRenderUnit;
+import org.apache.sling.scripting.spi.bundle.BundledRenderUnitFinder;
+import org.apache.sling.scripting.spi.bundle.TypeProvider;
+import org.apache.sling.servlets.resolver.internal.ResolverConfig;
+import org.apache.sling.servlets.resolver.internal.helper.SearchPathProvider;
+import org.apache.sling.servlets.resolver.internal.resource.ServletMounter;
+import org.apache.sling.testing.mock.osgi.junit.OsgiContext;
+import org.junit.Before;
+import org.junit.Rule;
import org.junit.Test;
+import org.mockito.ArgumentCaptor;
import org.osgi.framework.Bundle;
import org.osgi.framework.BundleContext;
import org.osgi.framework.BundleEvent;
+import org.osgi.framework.Constants;
+import org.osgi.framework.ServiceReference;
import org.osgi.framework.ServiceRegistration;
+import org.osgi.framework.Version;
+import org.osgi.framework.wiring.BundleCapability;
+import org.osgi.framework.wiring.BundleRevision;
+import org.osgi.framework.wiring.BundleWire;
+import org.osgi.framework.wiring.BundleWiring;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anySet;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
public class BundledScriptTrackerTest {
+ private static final String SCRIPT_PATH = "/apps/foo/foo.html";
+
+ @Rule
+ public final OsgiContext context = new OsgiContext();
+
+ private BundledScriptTracker tracker;
+ private ServletMounter mounter;
+
+ @Before
+ public void setUp() {
+ mounter = context.registerService(ServletMounter.class, mock(OSGiMockFriendlyServletMounter.class));
+
+ BundledRenderUnitFinder finder =
+ context.registerService(BundledRenderUnitFinder.class, mock(BundledRenderUnitFinder.class));
+
+ // the finder resolves every capability to a unit whose path is the indexed script path
+ final BundledRenderUnit unit = mock(BundledRenderUnit.class);
+ when(unit.getPath()).thenReturn(SCRIPT_PATH);
+ when(finder.findUnit(any(), any(TypeProvider.class), anySet())).thenReturn(unit);
+ when(finder.findUnit(any(), anySet(), anySet())).thenReturn(unit);
+
+ final SearchPathProvider searchPathProvider = mock(SearchPathProvider.class);
+ when(searchPathProvider.getSearchPaths()).thenReturn(List.of("/apps/", "/libs/"));
+ context.registerService(SearchPathProvider.class, searchPathProvider);
+
+ // registerInjectActivateService injects the (mandatory) references registered above and calls activate()
+ tracker = context.registerInjectActivateService(new BundledScriptTracker());
+ }
+
@Test
public void removedBundle() {
- BundledScriptTracker tracker = new BundledScriptTracker();
- tracker.activate(mock(BundleContext.class));
List<ServiceRegistration<Servlet>> registrations = new ArrayList<>();
@SuppressWarnings("unchecked")
ServiceRegistration<Servlet> registration = mock(ServiceRegistration.class);
@@ -45,4 +113,397 @@ public class BundledScriptTrackerTest {
tracker.removedBundle(mock(Bundle.class), mock(BundleEvent.class), registrations);
verify(registration).unregister();
}
+
+ /** A bundle that is not wired to the scripting extender contributes nothing. */
+ @Test
+ public void addingBundleNotWiredToExtender() {
+ final Bundle bundle = mock(Bundle.class);
+ final BundleWiring wiring = mock(BundleWiring.class);
+ when(bundle.adapt(BundleWiring.class)).thenReturn(wiring);
+ when(wiring.getRequiredWires("osgi.extender")).thenReturn(Collections.emptyList());
+
+ final List<ServiceRegistration<Servlet>> regs = tracker.addingBundle(bundle, mock(BundleEvent.class));
+
+ assertTrue("no registrations expected", regs.isEmpty());
+ assertTrue("no bundle should be tracked", tracker.getRegisteredBundles().isEmpty());
+ }
+
+ /**
+ * A bundle carrying a {@code Sling-Bundled-Scripts-Ranking} header registers its script with that value as the
+ * {@code service.ranking} property (verified on the merging-mode proxy reference).
+ */
+ @Test
+ public void addingBundleAppliesRankingHeader() {
+ when(mounter.mountProviders()).thenReturn(false);
+ final Bundle bundle = wiredScriptBundle("com.example.scripts", "42");
+
+ final List<ServiceRegistration<Servlet>> regs = tracker.addingBundle(bundle, mock(BundleEvent.class));
+
+ assertEquals(1, regs.size());
+ final ServiceReference<?> ref = capturedBoundReference();
+ assertEquals(SCRIPT_PATH, ref.getProperty(ServletResolverConstants.SLING_SERVLET_PATHS));
+ assertEquals(42, ref.getProperty(Constants.SERVICE_RANKING));
+ assertEquals("true", ref.getProperty(BundledHooks.class.getName()));
+ assertTrue("a synthetic service id is assigned", ref.getProperty(Constants.SERVICE_ID) instanceof Long);
+ assertTrue(tracker.getRegisteredBundles().contains("com.example.scripts"));
+ }
+
+ /** Without the header, no ranking is applied - the historical behaviour is preserved. */
+ @Test
+ public void addingBundleWithoutHeaderLeavesRankingUnset() {
+ when(mounter.mountProviders()).thenReturn(false);
+ final Bundle bundle = wiredScriptBundle("com.example.noheader", null);
+
+ tracker.addingBundle(bundle, mock(BundleEvent.class));
+
+ final ServiceReference<?> ref = capturedBoundReference();
+ assertNull(ref.getProperty(Constants.SERVICE_RANKING));
+ }
+
+ /** An unparseable header value is ignored, again leaving the ranking unset. */
+ @Test
+ public void addingBundleWithInvalidHeaderLeavesRankingUnset() {
+ when(mounter.mountProviders()).thenReturn(false);
+ final Bundle bundle = wiredScriptBundle("com.example.bad", "not-a-number");
+
+ tracker.addingBundle(bundle, mock(BundleEvent.class));
+
+ final ServiceReference<?> ref = capturedBoundReference();
+ assertNull(ref.getProperty(Constants.SERVICE_RANKING));
+ }
+
+ /** In non-merging mode the script is registered as a real OSGi service, with the ranking in its properties. */
+ @Test
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ public void addingBundleRegistersRealServiceWithRanking() {
+ when(mounter.mountProviders()).thenReturn(true);
+
+ final Bundle bundle = wiredScriptBundle("com.example.real", "7");
+ final BundleContext ctx = bundle.getBundleContext();
+
+ // register through a plain mock context so this does not trigger osgi-mock's automatic reference wiring
+ ServiceRegistration<Servlet> registration = createMockServiceRegistration();
+ when(ctx.registerService(eq(Servlet.class), any(Servlet.class), any())).thenReturn(registration);
+
+ final List<ServiceRegistration<Servlet>> regs = tracker.addingBundle(bundle, mock(BundleEvent.class));
+ assertEquals(1, regs.size());
+
+ final ArgumentCaptor<Dictionary> props = ArgumentCaptor.forClass(Dictionary.class);
+ verify(ctx).registerService(eq(Servlet.class), any(Servlet.class), props.capture());
+ assertEquals(SCRIPT_PATH, props.getValue().get(ServletResolverConstants.SLING_SERVLET_PATHS));
+ assertEquals(7, props.getValue().get(Constants.SERVICE_RANKING));
+ }
+
+ private static <T> ServiceRegistration<T> createMockServiceRegistration() {
+ final ServiceRegistration<T> registration = mock();
+ final ServiceReference<T> reference = mock();
+ when(registration.getReference()).thenReturn(reference);
+ return registration;
+ }
+
+ /** Removing a tracked bundle unregisters its script and drops it from the tracked set. */
+ @Test
+ public void removedBundleUnregistersScriptsAndUntracks() {
+ when(mounter.mountProviders()).thenReturn(false);
+ final Bundle bundle = wiredScriptBundle("com.example.scripts", "5");
+ final List<ServiceRegistration<Servlet>> regs = tracker.addingBundle(bundle, mock(BundleEvent.class));
+ assertTrue(tracker.getRegisteredBundles().contains("com.example.scripts"));
+
+ tracker.removedBundle(bundle, mock(BundleEvent.class), regs);
+
+ assertFalse(tracker.getRegisteredBundles().contains("com.example.scripts"));
+ verify(mounter).unbindServlet(any());
+ }
+
+ /** {@code modifiedBundle} is unexpected and merely logs; it must not fail or mutate state. */
+ @Test
+ public void modifiedBundleIsHarmless() {
+ tracker.modifiedBundle(mock(Bundle.class), mock(BundleEvent.class), Collections.emptyList());
+ assertTrue(tracker.getRegisteredBundles().isEmpty());
+ }
+
+ /** Re-binding the search path provider reconfigures the tracker without failing. */
+ @Test
+ public void rebindSearchPathProviderReopensTracker() {
+ final SearchPathProvider reconfigured = mock(SearchPathProvider.class);
+ when(reconfigured.getSearchPaths()).thenReturn(List.of("/apps/"));
+
+ tracker.bindSearchPathProvider(reconfigured);
+
+ assertTrue(tracker.getRegisteredBundles().isEmpty());
+ }
+
+ /** After deactivation the tracker no longer publishes and tolerates further callbacks. */
+ @Test
+ public void deactivateStopsPublishing() {
+ tracker.deactivate();
+ // refreshDispatcher must short-circuit on the now-null dispatcher map instead of throwing
+ tracker.removedBundle(mock(Bundle.class), mock(BundleEvent.class), Collections.emptyList());
+ assertTrue(tracker.getRegisteredBundles().isEmpty());
+ }
+
+ /** In merging mode the script is exposed as a synthetic proxy {@link ServiceReference} with full metadata. */
+ @Test
+ public void mergingModeProxyReferenceExposesMetadata() {
+ when(mounter.mountProviders()).thenReturn(false);
+ Bundle scriptTrackerBundle = this.context.bundleContext().getBundle();
+ Bundle scriptBundle = wiredScriptBundle("com.example.scripts", "3");
+ tracker.addingBundle(scriptBundle, mock(BundleEvent.class));
+
+ final ServiceReference<?> ref = capturedBoundReference();
+ assertTrue(ref instanceof Proxy);
+ assertSame(scriptBundle, ref.getBundle());
+ assertEquals(1, ref.getUsingBundles().length);
+ assertSame(scriptTrackerBundle, ref.getUsingBundles()[0]);
+ assertEquals(6, ref.getPropertyKeys().length);
+ assertTrue(ref.isAssignableTo(scriptBundle, Servlet.class.getName()));
+ assertTrue(ref.isAssignableTo(scriptTrackerBundle, Servlet.class.getName()));
+ // hashCode is the synthetic service id (0 for the first registration on a fresh tracker)
+ assertEquals(0, ref.hashCode());
+ }
+
+ /** The proxy references order by service ranking first, then by the synthetic (registration) service id. */
+ @Test
+ public void mergingModeProxyReferenceOrdering() {
+ when(mounter.mountProviders()).thenReturn(false);
+ tracker.addingBundle(wiredScriptBundle("com.example.a", "5"), mock(BundleEvent.class)); // id 0, rank 5
+ tracker.addingBundle(wiredScriptBundle("com.example.b", "5"), mock(BundleEvent.class)); // id 1, rank 5
+ tracker.addingBundle(wiredScriptBundle("com.example.c", "9"), mock(BundleEvent.class)); // id 2, rank 9
+
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ final ArgumentCaptor<ServiceReference> captor = ArgumentCaptor.forClass(ServiceReference.class);
+ verify(mounter, times(3)).bindServlet(any(Servlet.class), captor.capture());
+ final ServiceReference<?> a = captor.getAllValues().get(0);
+ final ServiceReference<?> b = captor.getAllValues().get(1);
+ final ServiceReference<?> c = captor.getAllValues().get(2);
+
+ assertEquals(0, a.compareTo(a)); // identical service
+ assertTrue(c.compareTo(a) > 0); // higher ranking wins
+ assertTrue(a.compareTo(c) < 0);
+ assertTrue(a.compareTo(b) > 0); // equal ranking: earlier registration (lower id) wins
+
+ // comparison against a non-bundled (real) reference treats the proxy's own id as -1
+ final ServiceReference<?> real = mock(ServiceReference.class);
+ when(real.getProperty(Constants.SERVICE_ID)).thenReturn(100L);
+ when(real.getProperty(Constants.SERVICE_RANKING)).thenReturn(0);
+ assertTrue(a.compareTo(real) > 0); // rank 5 beats rank 0
+ }
+
+ /** The synthetic {@link ServiceRegistration} proxy supports the registration lifecycle methods. */
+ @Test
+ public void mergingModeProxyRegistrationLifecycle() {
+ when(mounter.mountProviders()).thenReturn(false);
+ final List<ServiceRegistration<Servlet>> regs =
+ tracker.addingBundle(wiredScriptBundle("com.example.scripts", "1"), mock(BundleEvent.class));
+
+ final ServiceRegistration<Servlet> reg = regs.get(0);
+ assertNotNull(reg.getReference());
+ reg.setProperties(new Hashtable<>()); // no-op on the proxy
+ assertNotNull(reg.toString());
+ assertEquals(0, reg.hashCode()); // synthetic id of the first registration
+ }
+
+ /**
+ * A versioned resource-type script triggers registration of a dispatcher servlet; with nothing else tracked the
+ * dispatcher responds with 404. Exercises the resource-type registration branch, the dispatcher refresh and the
+ * dispatcher servlet itself.
+ */
+ @Test
+ public void resourceTypeScriptRegistersDispatcherServlet() throws Exception {
+ when(mounter.mountProviders()).thenReturn(false);
+ tracker.addingBundle(wiredResourceTypeBundle("com.example.rt"), mock(BundleEvent.class));
+
+ // in merging mode both the script and its dispatcher servlet are handed to the mounter
+ final ArgumentCaptor<Servlet> servletCaptor = ArgumentCaptor.forClass(Servlet.class);
+ verify(mounter, atLeastOnce()).bindServlet(servletCaptor.capture(), any());
+ final Servlet dispatcher = servletCaptor.getAllValues().stream()
+ .filter(BundledScriptTracker.DispatcherServlet.class::isInstance)
+ .findFirst()
+ .orElse(null);
+ assertNotNull("a dispatcher servlet must be registered for the resource type", dispatcher);
+
+ // with nothing else tracked the dispatcher cannot find a target and responds with 404
+ final SlingHttpServletRequest request = mock(SlingHttpServletRequest.class);
+ final SlingHttpServletResponse response = mock(SlingHttpServletResponse.class);
+ dispatcher.service(request, response);
+ verify(response).sendError(HttpServletResponse.SC_NOT_FOUND);
+ }
+
+ /**
+ * An "extends"-only capability is merged into the plain capability for the same resource type by {@code reduce()},
+ * and a resource-type script without extension/selectors/method exercises the servlet-path derivation branch.
+ */
+ @Test
+ public void mergedExtenderCapabilityRegistersSingleServlet() {
+ when(mounter.mountProviders()).thenReturn(false);
+
+ final Map<String, Object> extendsAttributes = new HashMap<>();
+ extendsAttributes.put(ServletResolverConstants.SLING_SERVLET_RESOURCE_TYPES, "foo/bar");
+ extendsAttributes.put(BundledScriptTracker.AT_EXTENDS, "foo/base");
+ final Map<String, Object> scriptAttributes = new HashMap<>();
+ scriptAttributes.put(ServletResolverConstants.SLING_SERVLET_RESOURCE_TYPES, "foo/bar");
+ scriptAttributes.put(BundledScriptTracker.AT_SCRIPT_ENGINE, "htl");
+
+ final Bundle bundle = wiredBundleMulti(
+ "com.example.merged",
+ List.of(capabilityWith(extendsAttributes), capabilityWith(scriptAttributes)),
+ Collections.emptyList());
+
+ final List<ServiceRegistration<Servlet>> regs = tracker.addingBundle(bundle, mock(BundleEvent.class));
+
+ assertEquals(1, regs.size());
+ assertTrue(tracker.getRegisteredBundles().contains("com.example.merged"));
+ }
+
+ /**
+ * A script that {@code extends} another resource type resolves its super-type from a wired {@code sling.servlet}
+ * capability, exercising the requires- and inheritance-chain collection.
+ */
+ @Test
+ public void resourceSuperTypeResolvedFromWiredCapability() {
+ when(mounter.mountProviders()).thenReturn(false);
+
+ final Map<String, Object> scriptAttributes = new HashMap<>();
+ scriptAttributes.put(ServletResolverConstants.SLING_SERVLET_RESOURCE_TYPES, "foo/bar");
+ scriptAttributes.put(BundledScriptTracker.AT_EXTENDS, "foo/base");
+ scriptAttributes.put(BundledScriptTracker.AT_SCRIPT_ENGINE, "htl");
+
+ // a wired capability that provides the extended (super) resource type
+ final Map<String, Object> superTypeAttributes = new HashMap<>();
+ superTypeAttributes.put(ServletResolverConstants.SLING_SERVLET_RESOURCE_TYPES, "foo/base");
+ final BundleWire superTypeWire = wireProviding(capabilityWith(superTypeAttributes));
+
+ final Bundle bundle = wiredBundleMulti(
+ "com.example.extends", List.of(capabilityWith(scriptAttributes)), List.of(superTypeWire));
+
+ final List<ServiceRegistration<Servlet>> regs = tracker.addingBundle(bundle, mock(BundleEvent.class));
+
+ // the script plus the wired super-type capability (which is also a script) are both registered
+ assertFalse(regs.isEmpty());
+ assertTrue(tracker.getRegisteredBundles().contains("com.example.extends"));
+ }
+
+ // ---------- helpers ----------
+
+ private static BundleCapability capabilityWith(final Map<String, Object> attributes) {
+ final BundleCapability capability = mock(BundleCapability.class);
+ when(capability.getAttributes()).thenReturn(attributes);
+ return capability;
+ }
+
+ /** A {@code sling.servlet} required wire whose provider exposes the given capability. */
+ private BundleWire wireProviding(final BundleCapability capability) {
+ final BundleWire wire = mock(BundleWire.class);
+ when(wire.getCapability()).thenReturn(capability);
+ final BundleRevision revision = mock(BundleRevision.class);
+ Bundle bundle = mock(Bundle.class);
+ when(revision.getBundle()).thenReturn(bundle);
+ when(wire.getProvider()).thenReturn(revision);
+ return wire;
+ }
+
+ /** Builds an extender-wired bundle exposing the given capabilities and {@code sling.servlet} required wires. */
+ private Bundle wiredBundleMulti(
+ final String symbolicName,
+ final List<BundleCapability> capabilities,
+ final List<BundleWire> slingServletWires) {
+ final Bundle bundle = mock(Bundle.class);
+ when(bundle.getSymbolicName()).thenReturn(symbolicName);
+ when(bundle.getBundleContext()).thenReturn(context.bundleContext());
+ when(bundle.getHeaders()).thenReturn(new Hashtable<>());
+
+ final BundleWiring wiring = mock(BundleWiring.class);
+ when(bundle.adapt(BundleWiring.class)).thenReturn(wiring);
+
+ final Bundle extenderBundle = context.bundleContext().getBundle();
+ final BundleRevision providerRevision = mock(BundleRevision.class);
+ when(providerRevision.getBundle()).thenReturn(extenderBundle);
+ final BundleWire extenderWire = mock(BundleWire.class);
+ when(extenderWire.getProvider()).thenReturn(providerRevision);
+ when(wiring.getRequiredWires("osgi.extender")).thenReturn(List.of(extenderWire));
+
+ when(wiring.getCapabilities(BundledScriptTracker.NS_SLING_SERVLET)).thenReturn(capabilities);
+ when(wiring.getRequiredWires(BundledScriptTracker.NS_SLING_SERVLET)).thenReturn(slingServletWires);
+ return bundle;
+ }
+
+ /**
+ * Builds a bundle that is wired to the scripting extender and exposes a single path-based {@code sling.servlet}
+ * capability, optionally declaring the ranking header.
+ */
+ private Bundle wiredScriptBundle(final String symbolicName, final String rankingHeader) {
+ final Map<String, Object> attributes = new HashMap<>();
+ attributes.put(ServletResolverConstants.SLING_SERVLET_PATHS, SCRIPT_PATH);
+ attributes.put(BundledScriptTracker.AT_SCRIPT_ENGINE, "htl");
+ return wiredBundle(symbolicName, rankingHeader, attributes);
+ }
+
+ /**
+ * Builds a bundle exposing a single versioned resource-type {@code sling.servlet} capability. The version makes
+ * the registration eligible for a dispatcher servlet.
+ */
+ private Bundle wiredResourceTypeBundle(final String symbolicName) {
+ final Map<String, Object> attributes = new HashMap<>();
+ attributes.put(ServletResolverConstants.SLING_SERVLET_RESOURCE_TYPES, "foo/bar");
+ attributes.put(BundledScriptTracker.AT_VERSION, Version.parseVersion("1.0.0"));
+ attributes.put(ServletResolverConstants.SLING_SERVLET_EXTENSIONS, "html");
+ attributes.put(BundledScriptTracker.AT_SCRIPT_ENGINE, "htl");
+ return wiredBundle(symbolicName, null, attributes);
+ }
+
+ private Bundle wiredBundle(
+ final String symbolicName, final String rankingHeader, final Map<String, Object> capabilityAttributes) {
+ final BundleContext bundleContext = mock();
+ final Bundle bundle = mock();
+ when(bundleContext.getBundle()).thenReturn(bundle);
+ when(bundle.getSymbolicName()).thenReturn(symbolicName);
+ when(bundle.getBundleContext()).thenReturn(bundleContext);
+
+ final Dictionary<String, String> headers = new Hashtable<>();
+ if (rankingHeader != null) {
+ headers.put(BundledScriptTracker.HEADER_SCRIPTS_RANKING, rankingHeader);
+ }
+ when(bundle.getHeaders()).thenReturn(headers);
+
+ final BundleWiring wiring = mock(BundleWiring.class);
+ when(bundle.adapt(BundleWiring.class)).thenReturn(wiring);
+
+ // extender gate: a required "osgi.extender" wire whose provider is this component's own bundle
+ final Bundle extenderBundle = context.bundleContext().getBundle();
+ final BundleRevision providerRevision = mock(BundleRevision.class);
+ when(providerRevision.getBundle()).thenReturn(extenderBundle);
+ final BundleWire extenderWire = mock(BundleWire.class);
+ when(extenderWire.getProvider()).thenReturn(providerRevision);
+ when(wiring.getRequiredWires("osgi.extender")).thenReturn(List.of(extenderWire));
+
+ final BundleCapability capability = mock(BundleCapability.class);
+ when(capability.getAttributes()).thenReturn(capabilityAttributes);
+ when(wiring.getCapabilities(BundledScriptTracker.NS_SLING_SERVLET)).thenReturn(List.of(capability));
+ when(wiring.getRequiredWires(BundledScriptTracker.NS_SLING_SERVLET)).thenReturn(Collections.emptyList());
+
+ return bundle;
+ }
+
+ /** Captures the proxy {@link ServiceReference} handed to the mounter in merging mode. */
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ private ServiceReference<?> capturedBoundReference() {
+ final ArgumentCaptor<ServiceReference> captor = ArgumentCaptor.forClass(ServiceReference.class);
+ verify(mounter).bindServlet(any(Servlet.class), captor.capture());
+ return captor.getValue();
+ }
+
+ // workaround: osgi-mocks only support parameters in the order ServiceReference, Servlet and not the reverse
+ private static class OSGiMockFriendlyServletMounter extends ServletMounter {
+ public OSGiMockFriendlyServletMounter(
+ BundleContext context,
+ ResourceResolverFactory resourceResolverFactory,
+ ServletContext servletContext,
+ ResolverConfig config) {
+ super(context, resourceResolverFactory, servletContext, config);
+ }
+
+ public void bindServlet(ServiceReference<javax.servlet.Servlet> reference, javax.servlet.Servlet servlet) {
+ super.bindServlet(servlet, reference);
+ }
+ }
}
diff --git a/src/test/java/org/apache/sling/servlets/resolver/internal/bundle/BundledScriptsRankingHeaderTest.java b/src/test/java/org/apache/sling/servlets/resolver/internal/bundle/BundledScriptsRankingHeaderTest.java
new file mode 100644
index 0000000..e5ac1c1
--- /dev/null
+++ b/src/test/java/org/apache/sling/servlets/resolver/internal/bundle/BundledScriptsRankingHeaderTest.java
@@ -0,0 +1,84 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.apache.sling.servlets.resolver.internal.bundle;
+
+import java.util.Dictionary;
+import java.util.Hashtable;
+
+import org.junit.Test;
+import org.osgi.framework.Bundle;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Verifies how {@link BundledScriptTracker#getBundledScriptsRanking(Bundle)} interprets the optional
+ * {@code Sling-Bundled-Scripts-Ranking} manifest header.
+ */
+public class BundledScriptsRankingHeaderTest {
+
+ /** No header present: behave like today, i.e. no ranking is derived. */
+ @Test
+ public void testAbsentHeaderYieldsNull() {
+ assertNull(BundledScriptTracker.getBundledScriptsRanking(bundleWithHeader(null)));
+ }
+
+ /** A valid integer header value is returned as the ranking. */
+ @Test
+ public void testValidHeaderIsParsed() {
+ assertEquals(Integer.valueOf(100), BundledScriptTracker.getBundledScriptsRanking(bundleWithHeader("100")));
+ }
+
+ /** Negative rankings are valid. */
+ @Test
+ public void testNegativeHeaderIsParsed() {
+ assertEquals(Integer.valueOf(-5), BundledScriptTracker.getBundledScriptsRanking(bundleWithHeader("-5")));
+ }
+
+ /** Surrounding whitespace is tolerated. */
+ @Test
+ public void testWhitespaceIsTrimmed() {
+ assertEquals(Integer.valueOf(42), BundledScriptTracker.getBundledScriptsRanking(bundleWithHeader(" 42 ")));
+ }
+
+ /** A non-integer value is treated as if the header was absent (a warning is logged). */
+ @Test
+ public void testNonIntegerHeaderIsTreatedAsAbsent() {
+ assertNull(BundledScriptTracker.getBundledScriptsRanking(bundleWithHeader("not-a-number")));
+ }
+
+ /** An empty value cannot be coerced to an int and is therefore treated as absent. */
+ @Test
+ public void testEmptyHeaderIsTreatedAsAbsent() {
+ assertNull(BundledScriptTracker.getBundledScriptsRanking(bundleWithHeader("")));
+ }
+
+ private static Bundle bundleWithHeader(final String value) {
+ final Dictionary<String, String> headers = new Hashtable<>();
+ if (value != null) {
+ headers.put(BundledScriptTracker.HEADER_SCRIPTS_RANKING, value);
+ }
+ final Bundle bundle = mock();
+ when(bundle.getHeaders()).thenReturn(headers);
+ when(bundle.getSymbolicName()).thenReturn("com.example.test");
+ return bundle;
+ }
+}