This is an automated email from the ASF dual-hosted git repository.

jsedding pushed a commit to branch master
in repository 
https://gitbox.apache.org/repos/asf/sling-org-apache-sling-servlets-resolver.git


The following commit(s) were added to refs/heads/master by this push:
     new ce29b57  SLING-13292 - Support ranking of bundles contributing bundled 
scripts (#73)
ce29b57 is described below

commit ce29b5717011ba6299bdc7d30212e67a69a42289
Author: Julian Sedding <[email protected]>
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 <[email protected]>
---
 .../internal/bundle/BundledScriptTracker.java      |  49 ++-
 .../internal/bundle/BundledScriptTrackerTest.java  | 469 ++++++++++++++++++++-
 .../bundle/BundledScriptsRankingHeaderTest.java    |  84 ++++
 3 files changed, 597 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 858ca4d..b8b6b95 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
@@ -107,6 +107,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;
 
@@ -182,11 +190,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();
@@ -205,13 +219,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<>();
@@ -615,7 +658,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 0df6370..2b93be7 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
@@ -18,25 +18,93 @@
  */
 package org.apache.sling.servlets.resolver.internal.bundle;
 
+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 jakarta.servlet.Servlet;
+import jakarta.servlet.ServletContext;
+import jakarta.servlet.http.HttpServletResponse;
+import org.apache.sling.api.SlingJakartaHttpServletRequest;
+import org.apache.sling.api.SlingJakartaHttpServletResponse;
+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);
@@ -44,4 +112,401 @@ 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).unbindJakartaServlet(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)).bindJakartaServlet(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()).bindJakartaServlet(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 SlingJakartaHttpServletRequest request = 
mock(SlingJakartaHttpServletRequest.class);
+        final SlingJakartaHttpServletResponse response = 
mock(SlingJakartaHttpServletResponse.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).bindJakartaServlet(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 bindJakartaServlet(ServiceReference<Servlet> reference, 
Servlet servlet) {
+            super.bindJakartaServlet(servlet, reference);
+        }
+
+        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;
+    }
+}

Reply via email to