This is an automated email from the ASF dual-hosted git repository.
benweidig pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/tapestry-5.git
The following commit(s) were added to refs/heads/master by this push:
new 8c40dd290 TAP5-2838: Make pipeline structure visible at runtime via
toString() (#68)
8c40dd290 is described below
commit 8c40dd29025b0dd37b3d816fd2161c2fdd6a4bb6
Author: Ben Weidig <[email protected]>
AuthorDate: Sat Oct 10 12:32:20 2026 +0200
TAP5-2838: Make pipeline structure visible at runtime via toString() (#68)
Not implemented as originally described in the Jira ticket.
Instead, Plastic introduces a new interface PipelineStep to each filter, so
they can describe themselves.
This allows for better introspection, not just better toString() output.
Also added a page to T5Dashboard to list the assembled pipelines and what
they contain.
---
.../apache/tapestry5/corelib/pages/Pipelines.java | 73 ++++++
.../apache/tapestry5/modules/DashboardModule.java | 1 +
.../apache/tapestry5/corelib/pages/Pipelines.tml | 60 +++++
.../integration/app1/CoreBehaviorsTests.java | 16 ++
.../ioc/internal/services/BridgeBuilder.java | 81 ++++++-
.../ioc/internal/services/PipelineBuilderImpl.java | 36 +++
.../ioc/internal/services/PipelineDescriber.java | 92 ++++++++
.../ioc/internal/services/PipelineStep.java | 34 +++
.../tapestry5/ioc/services/AssembledPipeline.java | 92 ++++++++
.../tapestry5/ioc/services/PipelineBuilder.java | 14 ++
.../test/groovy/ioc/specs/BridgeBuilderSpec.groovy | 178 --------------
.../ioc/specs/PipelineBuilderImplSpec.groovy | 88 -------
.../ioc/internal/services/BridgeBuilderTest.java | 258 +++++++++++++++++++++
.../internal/services/PipelineBuilderImplTest.java | 241 +++++++++++++++++++
.../apache/tapestry5/ioc/test/RecordingLogger.java | 177 ++++++++++++++
15 files changed, 1173 insertions(+), 268 deletions(-)
diff --git
a/tapestry-core/src/main/java/org/apache/tapestry5/corelib/pages/Pipelines.java
b/tapestry-core/src/main/java/org/apache/tapestry5/corelib/pages/Pipelines.java
new file mode 100644
index 000000000..bb2ccfa5b
--- /dev/null
+++
b/tapestry-core/src/main/java/org/apache/tapestry5/corelib/pages/Pipelines.java
@@ -0,0 +1,73 @@
+// Copyright 2026 The Apache Software Foundation
+//
+// 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 org.apache.tapestry5.corelib.pages;
+
+import java.util.List;
+
+import org.apache.tapestry5.annotations.Cached;
+import org.apache.tapestry5.annotations.Property;
+import org.apache.tapestry5.annotations.UnknownActivationContextCheck;
+import org.apache.tapestry5.annotations.WhitelistAccessOnly;
+import org.apache.tapestry5.http.TapestryHttpSymbolConstants;
+import org.apache.tapestry5.ioc.annotations.Inject;
+import org.apache.tapestry5.ioc.annotations.Symbol;
+import org.apache.tapestry5.ioc.services.AssembledPipeline;
+import org.apache.tapestry5.ioc.services.PipelineBuilder;
+
+/**
+ * Page used to see the pipelines assembled by the {@link PipelineBuilder}:
which filters are in each pipeline, and
+ * in which order they are invoked.
+ *
+ * A pipeline is assembled when the service it implements is first realized,
so this page shows more as the
+ * application runs.
+ *
+ * @since 5.10
+ */
+@UnknownActivationContextCheck(false)
+@WhitelistAccessOnly
+public class Pipelines
+{
+ @Inject
+ private PipelineBuilder pipelineBuilder;
+
+ @Property
+ @Inject
+ @Symbol(TapestryHttpSymbolConstants.PRODUCTION_MODE)
+ private boolean productionMode;
+
+ @Property
+ private AssembledPipeline pipeline;
+
+ @Property
+ private int pipelineIndex;
+
+ @Property
+ private String filter;
+
+ @Cached
+ public List<AssembledPipeline> getPipelines()
+ {
+ return pipelineBuilder.getAssembledPipelines();
+ }
+
+ /**
+ * Identifies the panel of the current pipeline, for the links at the top
of the page. The index is used rather
+ * than the service interface, since the same interface may be the basis
of more than one pipeline.
+ */
+ public String getPipelineClientId()
+ {
+ return "pipeline-" + pipelineIndex;
+ }
+}
diff --git
a/tapestry-core/src/main/java/org/apache/tapestry5/modules/DashboardModule.java
b/tapestry-core/src/main/java/org/apache/tapestry5/modules/DashboardModule.java
index c2938bf1e..01de67160 100644
---
a/tapestry-core/src/main/java/org/apache/tapestry5/modules/DashboardModule.java
+++
b/tapestry-core/src/main/java/org/apache/tapestry5/modules/DashboardModule.java
@@ -40,6 +40,7 @@ public class DashboardModule
{
configuration.add("Pages", new DashboardTab("Pages",
"core/PageCatalog"));
configuration.add("Services", new DashboardTab("Services",
"core/ServiceStatus"));
+ configuration.add("Pipelines", new DashboardTab("Pipelines",
"core/Pipelines"));
configuration.add("Libraries", new DashboardTab("ComponentLibraries",
"core/ComponentLibraries"));
configuration.add("PageDependencyGraph", new
DashboardTab("PageDependencyGraph", "core/PageDependencyGraph"));
if (!productionMode && multipleClassLoaders)
diff --git
a/tapestry-core/src/main/resources/org/apache/tapestry5/corelib/pages/Pipelines.tml
b/tapestry-core/src/main/resources/org/apache/tapestry5/corelib/pages/Pipelines.tml
new file mode 100644
index 000000000..555bbbb2b
--- /dev/null
+++
b/tapestry-core/src/main/resources/org/apache/tapestry5/corelib/pages/Pipelines.tml
@@ -0,0 +1,60 @@
+<t:block id="content"
+ xmlns:t="http://tapestry.apache.org/schema/tapestry_5_3.xsd"
xmlns:p="tapestry:parameter">
+
+ <t:unless test="productionMode">
+ <p:then>
+ <p>
+ <strong>${pipelines.size()}</strong> pipelines assembled so
far.
+ A pipeline is assembled when the service it implements is
first realized,
+ so this list grows as the application runs.
+ </p>
+ <ul class="list-group">
+ <li class="list-group-item" t:type="Loop" t:source="pipelines"
t:value="pipeline"
+ index="pipelineIndex">
+ <span class="badge">${pipeline.filters.size()}</span>
+ <a href="#${pipelineClientId}">
+ <code>${pipeline.serviceInterface.simpleName}</code>
+ </a>
+ </li>
+ </ul>
+
+ <div class="panel panel-default" t:type="Loop"
t:source="pipelines" t:value="pipeline"
+ index="pipelineIndex" id="${pipelineClientId}">
+
+ <div class="panel-heading">
+ <code>${pipeline.serviceInterface.name}</code>
+ </div>
+
+ <div class="panel-body">
+ <p>
+ Filtered by:
<code>${pipeline.filterInterface.name}</code>
+ </p>
+
+ <t:if test="pipeline.filters.empty">
+ <p:then>
+ <p class="text-muted">No filters contributed; the
pipeline is just its terminator.</p>
+ </p:then>
+ <p:else>
+ <ol>
+ <li t:type="Loop" t:source="pipeline.filters"
t:value="filter">
+ <code>${filter}</code>
+ </li>
+ </ol>
+ </p:else>
+ </t:if>
+
+ <p>
+ Terminator:
+ <code>${pipeline.terminator}</code>
+ </p>
+ </div>
+ </div>
+ </p:then>
+ <p:else>
+ <p>
+ <em>The filters of each pipeline are only shown in development
mode.</em>
+ </p>
+ </p:else>
+ </t:unless>
+
+</t:block>
diff --git
a/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/CoreBehaviorsTests.java
b/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/CoreBehaviorsTests.java
index a7a538510..b46c1c0e9 100644
---
a/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/CoreBehaviorsTests.java
+++
b/tapestry-core/src/test/java/org/apache/tapestry5/integration/app1/CoreBehaviorsTests.java
@@ -14,6 +14,7 @@ package org.apache.tapestry5.integration.app1;
import org.apache.tapestry5.corelib.mixins.RenderDisabled;
import org.apache.tapestry5.integration.app1.pages.RenderErrorDemo;
+import org.apache.tapestry5.services.ComponentRequestHandler;
import org.openqa.selenium.By;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.testng.annotations.Test;
@@ -743,6 +744,21 @@ public class CoreBehaviorsTests extends App1TestCase
assertTextPresent("services defined in the IoC Registry");
}
+ /**
+ * This basically checks that the pipelines page does not error.
+ */
+ @Test
+ public void pipelines()
+ {
+ open(getBaseURL() + "t5dashboard/pipelines");
+
+ assertTextPresent("pipelines assembled so far");
+
+ // Every request goes through this pipeline, so it has certainly been
assembled by now.
+
+ assertTextPresent(ComponentRequestHandler.class.getName());
+ }
+
/**
* Tests TAPESTRY-1934.
*/
diff --git
a/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/internal/services/BridgeBuilder.java
b/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/internal/services/BridgeBuilder.java
index 63f6255c5..64c7ac74c 100644
---
a/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/internal/services/BridgeBuilder.java
+++
b/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/internal/services/BridgeBuilder.java
@@ -27,12 +27,20 @@ import java.util.List;
*/
public class BridgeBuilder<S, F>
{
+ private static final MethodDescription GET_PIPELINE_FILTER = new
MethodDescription("java.lang.Object",
+ "getPipelineFilter");
+
+ private static final MethodDescription GET_PIPELINE_NEXT = new
MethodDescription("java.lang.Object",
+ "getPipelineNext");
+
private final Logger logger;
private final Class<S> serviceInterface;
private final Class<F> filterInterface;
+ private final String header;
+
private final FilterMethodAnalyzer filterMethodAnalyzer;
private final PlasticProxyFactory proxyFactory;
@@ -47,6 +55,8 @@ public class BridgeBuilder<S, F>
this.proxyFactory = proxyFactory;
+ header = PipelineDescriber.header(serviceInterface, filterInterface);
+
filterMethodAnalyzer = new FilterMethodAnalyzer(serviceInterface);
}
@@ -80,8 +90,75 @@ public class BridgeBuilder<S, F>
processMethods(plasticClass, filterField, nextField);
- plasticClass.addToString(String.format("<PipelineBridge from
%s to %s>", serviceInterface.getName(),
- filterInterface.getName()));
+ introducePipelineStep(plasticClass, filterField, nextField);
+ }
+ });
+ }
+
+ /**
+ * Makes the bridge a {@link PipelineStep}, exposing the filter it wraps
and what it delegates to, and adds a
+ * {@code toString()} that describes the pipeline from this step onwards.
+ */
+ private void introducePipelineStep(PlasticClass plasticClass, final
PlasticField filterField,
+ final PlasticField nextField)
+ {
+ // Rule out that a service interface declares one of the methods we
want to introduce.
+ // Fall back to a plain description.
+
+ if (plasticClass.isMethodImplemented(GET_PIPELINE_FILTER) ||
plasticClass.isMethodImplemented(GET_PIPELINE_NEXT))
+ {
+ plasticClass.addToString(header);
+
+ return;
+ }
+
+ plasticClass.introduceInterface(PipelineStep.class);
+
+ plasticClass.introduceMethod(GET_PIPELINE_FILTER, new
InstructionBuilderCallback()
+ {
+ @Override
+ public void doBuild(InstructionBuilder builder)
+ {
+ builder.loadThis().getField(filterField).returnResult();
+ }
+ });
+
+ plasticClass.introduceMethod(GET_PIPELINE_NEXT, new
InstructionBuilderCallback()
+ {
+ @Override
+ public void doBuild(InstructionBuilder builder)
+ {
+ builder.loadThis().getField(nextField).returnResult();
+ }
+ });
+
+ addToString(plasticClass);
+ }
+
+ /**
+ * Adds a {@code toString()} that delegates to
+ * {@link PipelineDescriber#describe(String, PipelineStep)}, describing
the whole chain from this bridge onwards.
+ *
+ * Nothing is added when {@code toString()} is part of the service
interface itself; in that case the method is
+ * already bridged to the filter, and that takes precedence. The pipeline
can still be walked as a
+ * {@link PipelineStep}.
+ */
+ private void addToString(PlasticClass plasticClass)
+ {
+ if
(plasticClass.isMethodImplemented(PlasticUtils.TO_STRING_DESCRIPTION))
+ return;
+
+ plasticClass.introduceMethod(PlasticUtils.TO_STRING_DESCRIPTION, new
InstructionBuilderCallback()
+ {
+ @Override
+ public void doBuild(InstructionBuilder builder)
+ {
+ builder.loadConstant(header).loadThis();
+
+ builder.invokeStatic(PipelineDescriber.class, String.class,
"describe", String.class,
+ PipelineStep.class);
+
+ builder.returnResult();
}
});
}
diff --git
a/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/internal/services/PipelineBuilderImpl.java
b/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/internal/services/PipelineBuilderImpl.java
index a2d877a2e..8e088866d 100644
---
a/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/internal/services/PipelineBuilderImpl.java
+++
b/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/internal/services/PipelineBuilderImpl.java
@@ -14,9 +14,13 @@
package org.apache.tapestry5.ioc.internal.services;
+import java.util.ArrayList;
+import java.util.Collections;
import java.util.List;
import org.apache.tapestry5.commons.services.PlasticProxyFactory;
+import org.apache.tapestry5.commons.util.CollectionFactory;
+import org.apache.tapestry5.ioc.services.AssembledPipeline;
import org.apache.tapestry5.ioc.services.Builtin;
import org.apache.tapestry5.ioc.services.DefaultImplementationBuilder;
import org.apache.tapestry5.ioc.services.PipelineBuilder;
@@ -29,6 +33,8 @@ public class PipelineBuilderImpl implements PipelineBuilder
private final DefaultImplementationBuilder defaultImplementationBuilder;
+ private final List<AssembledPipeline> assembledPipelines =
CollectionFactory.newThreadSafeList();
+
public PipelineBuilderImpl(@Builtin
PlasticProxyFactory proxyFactory,
@@ -50,6 +56,8 @@ public class PipelineBuilderImpl implements PipelineBuilder
public <S, F> S build(Logger logger, Class<S> serviceInterface, Class<F>
filterInterface, List<F> filters,
S terminator)
{
+ track(serviceInterface, filterInterface, filters, terminator);
+
if (filters.isEmpty())
return terminator;
@@ -69,7 +77,35 @@ public class PipelineBuilderImpl implements PipelineBuilder
next = bb.instantiateBridge(next, filter);
}
+ if (logger.isDebugEnabled())
+ {
+ logger.debug("Assembled pipeline: {}", next);
+ }
+
return next;
}
+ @Override
+ public List<AssembledPipeline> getAssembledPipelines()
+ {
+ return Collections.unmodifiableList(assembledPipelines);
+ }
+
+ /**
+ * Records the pipeline for later introspection, describing the filters
and the terminator as they are now rather
+ * than holding on to them.
+ */
+ private <S, F> void track(Class<S> serviceInterface, Class<F>
filterInterface, List<F> filters, S terminator)
+ {
+ List<String> descriptions = new ArrayList<>(filters.size());
+
+ for (F filter : filters)
+ {
+ descriptions.add(String.valueOf(filter));
+ }
+
+ assembledPipelines.add(new AssembledPipeline(serviceInterface,
filterInterface, descriptions,
+ String.valueOf(terminator)));
+ }
+
}
diff --git
a/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/internal/services/PipelineDescriber.java
b/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/internal/services/PipelineDescriber.java
new file mode 100644
index 000000000..7209b726f
--- /dev/null
+++
b/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/internal/services/PipelineDescriber.java
@@ -0,0 +1,92 @@
+// Copyright 2026 The Apache Software Foundation
+//
+// 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 org.apache.tapestry5.ioc.internal.services;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Describes an assembled pipeline as {@code header: filter -> filter ->
terminator}.
+ *
+ * <p>The {@code toString()} of the bridge classes generated by {@link
BridgeBuilder} delegates here,
+ * as does {@link org.apache.tapestry5.ioc.services.AssembledPipeline}.
+ * @since 5.10.0
+ */
+public final class PipelineDescriber
+{
+ private static final String SEPARATOR = " -> ";
+
+ private PipelineDescriber()
+ {
+ }
+
+ /**
+ * The header identifying a pipeline, ahead of its steps.
+ */
+ public static String header(Class<?> serviceInterface, Class<?>
filterInterface)
+ {
+ return String.format("<PipelineBridge from %s to %s>",
serviceInterface.getName(), filterInterface.getName());
+ }
+
+ /**
+ * Walks the pipeline from the given step to its end, describing each
filter, including the terminator.
+ *
+ * @param header
+ * identifies the pipeline, the steps follow it, separated by a
colon
+ * @param step
+ * where to start, typically the outermost bridge, but any step
describes the pipeline from itself
+ * onwards
+ */
+ public static String describe(String header, PipelineStep step)
+ {
+ List<Object> steps = new ArrayList<>();
+
+ Object current = step;
+
+ while (current instanceof PipelineStep)
+ {
+ PipelineStep pipelineStep = (PipelineStep) current;
+
+ steps.add(pipelineStep.getPipelineFilter());
+
+ current = pipelineStep.getPipelineNext();
+ }
+
+ steps.add(current);
+
+ return describe(header, steps);
+ }
+
+ /**
+ * Describes steps that have already been collected: the filters, in the
order they are invoked, followed by the
+ * terminator.
+ */
+ public static String describe(String header, List<?> steps)
+ {
+ StringBuilder builder = new StringBuilder(header).append(": ");
+
+ for (int i = 0; i < steps.size(); i++)
+ {
+ if (i > 0)
+ {
+ builder.append(SEPARATOR);
+ }
+
+ builder.append(steps.get(i));
+ }
+
+ return builder.toString();
+ }
+}
diff --git
a/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/internal/services/PipelineStep.java
b/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/internal/services/PipelineStep.java
new file mode 100644
index 000000000..b61e04278
--- /dev/null
+++
b/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/internal/services/PipelineStep.java
@@ -0,0 +1,34 @@
+// Copyright 2026 The Apache Software Foundation
+//
+// 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 org.apache.tapestry5.ioc.internal.services;
+
+/**
+ * Implemented by the bridge classes generated by {@link BridgeBuilder},
+ * to expose one step of an assembled pipeline.
+ *
+ * @since 5.10.0
+ */
+public interface PipelineStep
+{
+ /**
+ * The filter invoked at this step of the pipeline.
+ */
+ Object getPipelineFilter();
+
+ /**
+ * What this step delegates to: the next {@link PipelineStep}, or the
terminator at the end of the pipeline.
+ */
+ Object getPipelineNext();
+}
diff --git
a/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/services/AssembledPipeline.java
b/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/services/AssembledPipeline.java
new file mode 100644
index 000000000..543f33b0e
--- /dev/null
+++
b/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/services/AssembledPipeline.java
@@ -0,0 +1,92 @@
+// Copyright 2026 The Apache Software Foundation
+//
+// 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 org.apache.tapestry5.ioc.services;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.tapestry5.ioc.internal.services.PipelineDescriber;
+
+/**
+ * A record of a pipeline assembled by {@link PipelineBuilder}: the service
and filter interfaces it bridges,
+ * the filters in the order they are invoked, and the terminator at the end of
the chain.
+ *
+ * The filters and the terminator are recorded as descriptions, taken when the
pipeline was assembled,
+ * rather than as objects, so that a pipeline no longer in use is not kept
alive by this record.
+ *
+ * @since 5.10.0
+ */
+public final class AssembledPipeline
+{
+ private final Class<?> serviceInterface;
+
+ private final Class<?> filterInterface;
+
+ private final List<String> filters;
+
+ private final String terminator;
+
+ public AssembledPipeline(Class<?> serviceInterface, Class<?>
filterInterface, List<String> filters,
+ String terminator)
+ {
+ this.serviceInterface = serviceInterface;
+ this.filterInterface = filterInterface;
+ this.filters = Collections.unmodifiableList(new ArrayList<>(filters));
+ this.terminator = terminator;
+ }
+
+ /**
+ * The interface the pipeline implements.
+ */
+ public Class<?> getServiceInterface()
+ {
+ return serviceInterface;
+ }
+
+ /**
+ * The interface implemented by each filter of the pipeline.
+ */
+ public Class<?> getFilterInterface()
+ {
+ return filterInterface;
+ }
+
+ /**
+ * The filters, in the order they are invoked. Empty when the pipeline is
just its terminator.
+ */
+ public List<String> getFilters()
+ {
+ return filters;
+ }
+
+ /**
+ * What the last filter delegates to.
+ */
+ public String getTerminator()
+ {
+ return terminator;
+ }
+
+ @Override
+ public String toString()
+ {
+ List<String> steps = new ArrayList<>(filters);
+
+ steps.add(terminator);
+
+ return
PipelineDescriber.describe(PipelineDescriber.header(serviceInterface,
filterInterface), steps);
+ }
+}
diff --git
a/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/services/PipelineBuilder.java
b/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/services/PipelineBuilder.java
index 2cfa200e8..b272fddd5 100644
---
a/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/services/PipelineBuilder.java
+++
b/tapestry-ioc/src/main/java/org/apache/tapestry5/ioc/services/PipelineBuilder.java
@@ -14,6 +14,7 @@ package org.apache.tapestry5.ioc.services;
import org.slf4j.Logger;
+import java.util.Collections;
import java.util.List;
/**
@@ -63,4 +64,17 @@ public interface PipelineBuilder
*/
<S, F> S build(Logger logger, Class<S> serviceInterface, Class<F>
filterInterface, List<F> filters);
+ /**
+ * The pipelines assembled so far, in the order they were assembled.
+ *
+ * <p>A pipeline is assembled when the service it implements is first
realized, so the list grows as the application runs.
+ *
+ * @return the assembled pipelines, or an empty list for an implementation
that does not keep track of them
+ * @since 5.10.0
+ */
+ default List<AssembledPipeline> getAssembledPipelines()
+ {
+ return Collections.emptyList();
+ }
+
}
diff --git a/tapestry-ioc/src/test/groovy/ioc/specs/BridgeBuilderSpec.groovy
b/tapestry-ioc/src/test/groovy/ioc/specs/BridgeBuilderSpec.groovy
deleted file mode 100644
index 57339a1e9..000000000
--- a/tapestry-ioc/src/test/groovy/ioc/specs/BridgeBuilderSpec.groovy
+++ /dev/null
@@ -1,178 +0,0 @@
-package ioc.specs
-
-import org.apache.tapestry5.commons.services.PlasticProxyFactory
-import org.apache.tapestry5.ioc.internal.services.*
-import org.apache.tapestry5.ioc.test.internal.services.ExtraFilterMethod
-import org.apache.tapestry5.ioc.test.internal.services.ExtraServiceMethod
-import org.apache.tapestry5.ioc.test.internal.services.MiddleFilter
-import org.apache.tapestry5.ioc.test.internal.services.MiddleService
-import org.apache.tapestry5.ioc.test.internal.services.StandardFilter
-import org.apache.tapestry5.ioc.test.internal.services.StandardService
-import org.apache.tapestry5.ioc.test.internal.services.ToStringFilter
-import org.apache.tapestry5.ioc.test.internal.services.ToStringService
-import org.slf4j.Logger
-
-import spock.lang.Shared
-
-class BridgeBuilderSpec extends AbstractSharedRegistrySpecification {
-
- @Shared
- PlasticProxyFactory proxyFactory;
-
- def setupSpec() {
- proxyFactory = getService PlasticProxyFactory
- }
-
- def "toString() of proxy is as expected"() {
- Logger logger = Mock()
- StandardFilter sf = Mock()
- StandardService ss = Mock()
-
- BridgeBuilder builder = new BridgeBuilder(logger, StandardService,
StandardFilter, proxyFactory)
-
- when:
-
- def bridge = builder.instantiateBridge(ss, sf)
-
- then:
-
- bridge.toString() == "<PipelineBridge from
org.apache.tapestry5.ioc.test.internal.services.StandardService to
org.apache.tapestry5.ioc.test.internal.services.StandardFilter>"
- }
-
- def "standard service and interface"() {
- Logger logger = Mock()
- StandardFilter sf = Mock()
- StandardService ss = Mock()
-
- BridgeBuilder builder = new BridgeBuilder(logger, StandardService,
StandardFilter, proxyFactory)
- def bridge = builder.instantiateBridge(ss, sf)
-
- when:
-
- assert bridge.run(5) == 18
-
- // 18 = 3 * (5 + 1)
- // so the filter runs first, and passes 6 to the service
- // seems there's an issue in Spock with chaining mocks this way
-
- then:
-
- 1 * sf.run(_, _) >> { i, service -> service.run(i + 1) }
-
- 1 * ss.run(_) >> { i -> 3 * i }
-
- 0 * _
- }
-
- def "when toString() is part of service interface, it is forwarded through
the filter"() {
- Logger logger = Mock()
-
- ToStringService service = new ToStringService() {
-
- String toString() { "Service" }
- }
-
- ToStringFilter filter = new ToStringFilter() {
-
- String toString(ToStringService s) {
- s.toString().toUpperCase()
- }
- }
-
- BridgeBuilder builder = new BridgeBuilder(logger, ToStringService,
ToStringFilter, proxyFactory)
-
- when:
-
- ToStringService bridge = builder.instantiateBridge(service, filter)
-
- then:
-
- bridge.toString() == "SERVICE"
- }
-
- def "unmatched service interface method is logged and exception thrown"() {
- Logger logger = Mock()
- ExtraServiceMethod next = Mock()
- Serializable filter = Mock()
-
- BridgeBuilder builder = new BridgeBuilder(logger, ExtraServiceMethod,
Serializable, proxyFactory)
-
- when:
-
- ExtraServiceMethod esm = builder.instantiateBridge(next, filter)
-
- then:
-
- 1 * logger.error("Method void extraServiceMethod() has no match in filter
interface java.io.Serializable.")
-
- when:
-
- esm.extraServiceMethod()
-
- then:
-
- RuntimeException e = thrown()
-
- e.message == "Method void extraServiceMethod() has no match in filter
interface java.io.Serializable."
- }
-
- def "extra methods in filter interface are logged and ignored"() {
- Logger logger = Mock()
- Serializable next = Mock()
- ExtraFilterMethod filter = Mock()
-
- BridgeBuilder builder = new BridgeBuilder(logger, Serializable,
ExtraFilterMethod, proxyFactory)
-
- when:
-
- assert builder.instantiateBridge(next, filter) != null
-
- then:
-
- 1 * logger.error('Method {} of filter interface {} does not have a
matching method in {}.', { it.name == "extraFilterMethod" },
ExtraFilterMethod.name, Serializable.name)
-
- 0 * _
- }
-
- def "the service parameter may be a middle parameter of the filter method"()
{
- Logger logger = Mock()
-
- MiddleFilter mf = new MiddleFilter() {
-
- @Override
- void execute(int count, char ch, MiddleService service, StringBuilder
buffer) {
- service.execute(count, ch, buffer)
-
- buffer.append(' ')
-
- service.execute(count + 1, Character.toUpperCase(ch), buffer)
- }
- }
-
- MiddleService ms = new MiddleService() {
-
- @Override
- void execute(int count, char ch, StringBuilder buffer) {
- count.times() { buffer.append ch }
- }
- }
-
- BridgeBuilder builder = new BridgeBuilder(logger, MiddleService,
MiddleFilter, proxyFactory)
-
-
- MiddleService bridge = builder.instantiateBridge(ms, mf)
-
- StringBuilder buffer = new StringBuilder("CODE: ")
-
- when:
-
-
- bridge.execute(3, 'a' as char, buffer)
-
- then:
-
- buffer.toString() == "CODE: aaa AAAA"
-
- }
-
-}
diff --git
a/tapestry-ioc/src/test/groovy/ioc/specs/PipelineBuilderImplSpec.groovy
b/tapestry-ioc/src/test/groovy/ioc/specs/PipelineBuilderImplSpec.groovy
deleted file mode 100644
index f543c28de..000000000
--- a/tapestry-ioc/src/test/groovy/ioc/specs/PipelineBuilderImplSpec.groovy
+++ /dev/null
@@ -1,88 +0,0 @@
-package ioc.specs
-
-import org.apache.tapestry5.ioc.services.PipelineBuilder
-import org.apache.tapestry5.ioc.test.internal.services.StandardFilter
-import org.apache.tapestry5.ioc.test.internal.services.StandardService
-import org.slf4j.Logger
-
-import spock.lang.Shared
-
-class PipelineBuilderImplSpec extends AbstractSharedRegistrySpecification {
-
- @Shared
- PipelineBuilder builder
-
- def setupSpec() { builder = getService PipelineBuilder }
-
- Logger logger = Mock()
-
- def "standard pipeline with filters"() {
-
- // For some reason, this didn't work with closures, just with actual inner
classes
-
- StandardFilter subtracter = new StandardFilter() {
-
- @Override
- int run(int i, StandardService service) {
- service.run(i) - 2
- }
- }
-
- StandardFilter multiplier = new StandardFilter() {
-
- @Override
- int run(int i, StandardService service) {
- 2 * service.run(i)
- }
- }
-
- StandardFilter adder = new StandardFilter() {
-
- @Override
- int run(int i, StandardService service) {
- service.run(i + 3)
- }
- }
-
- StandardService terminator = new StandardService() {
-
- @Override
- int run(int i) {
- i
- }
- }
-
- when:
-
- StandardService pipeline = builder.build logger, StandardService,
StandardFilter, [subtracter, multiplier, adder], terminator
-
- then:
-
- pipeline.run(5) == 14
- pipeline.run(10) == 24
- }
-
- def "a pipeline without filters is simply the temrinator"() {
-
- StandardService terminator = Mock()
-
- when:
-
- StandardService pipeline = builder.build logger, StandardService,
StandardFilter, [], terminator
-
- then:
-
- pipeline.is terminator
- }
-
- def "a pipeline with no filters and no terminator does nothing"() {
- when:
-
- StandardService pipeline = builder.build logger, StandardService,
StandardFilter, []
-
- then:
-
- pipeline.run(99) == 0
-
- }
-}
diff --git
a/tapestry-ioc/src/test/java/org/apache/tapestry5/ioc/internal/services/BridgeBuilderTest.java
b/tapestry-ioc/src/test/java/org/apache/tapestry5/ioc/internal/services/BridgeBuilderTest.java
new file mode 100644
index 000000000..6dd3d2e43
--- /dev/null
+++
b/tapestry-ioc/src/test/java/org/apache/tapestry5/ioc/internal/services/BridgeBuilderTest.java
@@ -0,0 +1,258 @@
+// Copyright 2026 The Apache Software Foundation
+//
+// 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 org.apache.tapestry5.ioc.internal.services;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.Serializable;
+import java.util.Collections;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.tapestry5.beanmodel.services.PlasticProxyFactoryImpl;
+import org.apache.tapestry5.commons.services.PlasticProxyFactory;
+import org.apache.tapestry5.ioc.test.RecordingLogger;
+import org.apache.tapestry5.ioc.test.internal.services.ExtraFilterMethod;
+import org.apache.tapestry5.ioc.test.internal.services.ExtraServiceMethod;
+import org.apache.tapestry5.ioc.test.internal.services.MiddleFilter;
+import org.apache.tapestry5.ioc.test.internal.services.MiddleService;
+import org.apache.tapestry5.ioc.test.internal.services.StandardFilter;
+import org.apache.tapestry5.ioc.test.internal.services.StandardService;
+import org.apache.tapestry5.ioc.test.internal.services.ToStringFilter;
+import org.apache.tapestry5.ioc.test.internal.services.ToStringService;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.slf4j.event.Level;
+
+class BridgeBuilderTest
+{
+ private static final String STANDARD_SERVICE =
StandardService.class.getName();
+
+ private static final String STANDARD_FILTER =
StandardFilter.class.getName();
+
+ private static PlasticProxyFactory proxyFactory;
+
+ private RecordingLogger logger;
+
+ @BeforeAll
+ static void createProxyFactory()
+ {
+ proxyFactory = new
PlasticProxyFactoryImpl(BridgeBuilderTest.class.getClassLoader(), null);
+ }
+
+ @BeforeEach
+ void createLogger()
+ {
+ logger = new RecordingLogger();
+ }
+
+ @Test
+ void toStringOfBridgeDescribesThePipeline()
+ {
+ StandardFilter filter = new StandardFilter()
+ {
+ @Override
+ public int run(int i, StandardService service)
+ {
+ return service.run(i);
+ }
+
+ @Override
+ public String toString()
+ {
+ return "FILTER";
+ }
+ };
+
+ StandardService next = new StandardService()
+ {
+ @Override
+ public int run(int i)
+ {
+ return i;
+ }
+
+ @Override
+ public String toString()
+ {
+ return "NEXT";
+ }
+ };
+
+ BridgeBuilder<StandardService, StandardFilter> builder = new
BridgeBuilder<>(logger, StandardService.class,
+ StandardFilter.class, proxyFactory);
+
+ StandardService bridge = builder.instantiateBridge(next, filter);
+
+ assertEquals(String.format("<PipelineBridge from %s to %s>: FILTER ->
NEXT", STANDARD_SERVICE, STANDARD_FILTER),
+ bridge.toString());
+ }
+
+ @Test
+ void bridgeExposesItsFilterAndItsNextStep()
+ {
+ StandardFilter filter = (i, service) -> service.run(i);
+
+ StandardService next = i -> i;
+
+ BridgeBuilder<StandardService, StandardFilter> builder = new
BridgeBuilder<>(logger, StandardService.class,
+ StandardFilter.class, proxyFactory);
+
+ PipelineStep bridge = (PipelineStep) builder.instantiateBridge(next,
filter);
+
+ assertSame(filter, bridge.getPipelineFilter());
+ assertSame(next, bridge.getPipelineNext());
+ }
+
+ @Test
+ void standardServiceAndInterface()
+ {
+ AtomicInteger filterCount = new AtomicInteger();
+ AtomicInteger serviceCount = new AtomicInteger();
+
+ // The filter runs first and passes 6 to the service, so the result is
3 * (5 + 1).
+
+ StandardFilter filter = (i, service) ->
+ {
+ filterCount.incrementAndGet();
+
+ return service.run(i + 1);
+ };
+
+ StandardService next = i ->
+ {
+ serviceCount.incrementAndGet();
+
+ return 3 * i;
+ };
+
+ BridgeBuilder<StandardService, StandardFilter> builder = new
BridgeBuilder<>(logger, StandardService.class,
+ StandardFilter.class, proxyFactory);
+
+ StandardService bridge = builder.instantiateBridge(next, filter);
+
+ assertEquals(18, bridge.run(5));
+
+ assertEquals(1, filterCount.get());
+ assertEquals(1, serviceCount.get());
+ }
+
+ @Test
+ void whenToStringIsPartOfServiceInterfaceItIsForwardedThroughTheFilter()
+ {
+ ToStringService service = new ToStringService()
+ {
+ @Override
+ public String toString()
+ {
+ return "Service";
+ }
+ };
+
+ ToStringFilter filter = service1 -> service1.toString().toUpperCase();
+
+ BridgeBuilder<ToStringService, ToStringFilter> builder = new
BridgeBuilder<>(logger, ToStringService.class,
+ ToStringFilter.class, proxyFactory);
+
+ ToStringService bridge = builder.instantiateBridge(service, filter);
+
+ assertEquals("SERVICE", bridge.toString());
+
+ // toString() is taken by the service interface, but the pipeline can
still be walked.
+
+ assertSame(filter, ((PipelineStep) bridge).getPipelineFilter());
+ assertSame(service, ((PipelineStep) bridge).getPipelineNext());
+ }
+
+ @Test
+ void unmatchedServiceInterfaceMethodIsLoggedAndExceptionThrown()
+ {
+ String expectedMessage = "Method void extraServiceMethod() has no
match in filter interface java.io.Serializable.";
+
+ ExtraServiceMethod next = () ->
+ {
+ };
+
+ Serializable filter = new Serializable()
+ {
+ };
+
+ BridgeBuilder<ExtraServiceMethod, Serializable> builder = new
BridgeBuilder<>(logger, ExtraServiceMethod.class,
+ Serializable.class, proxyFactory);
+
+ ExtraServiceMethod bridge = builder.instantiateBridge(next, filter);
+
+ assertEquals(Collections.singletonList(expectedMessage),
logger.getMessages(Level.ERROR));
+
+ RuntimeException e = assertThrows(RuntimeException.class,
bridge::extraServiceMethod);
+
+ assertEquals(expectedMessage, e.getMessage());
+ }
+
+ @Test
+ void extraMethodsInFilterInterfaceAreLoggedAndIgnored()
+ {
+ Serializable next = new Serializable()
+ {
+ };
+
+ ExtraFilterMethod filter = () ->
+ {
+ };
+
+ BridgeBuilder<Serializable, ExtraFilterMethod> builder = new
BridgeBuilder<>(logger, Serializable.class,
+ ExtraFilterMethod.class, proxyFactory);
+
+ assertNotNull(builder.instantiateBridge(next, filter));
+
+ assertEquals(Collections.singletonList(String.format(
+ "Method void extraFilterMethod() of filter interface %s does
not have a matching method in %s.",
+ ExtraFilterMethod.class.getName(),
Serializable.class.getName())), logger.getMessages(Level.ERROR));
+ }
+
+ @Test
+ void theServiceParameterMayBeAMiddleParameterOfTheFilterMethod()
+ {
+ MiddleFilter filter = (count, ch, service, buffer) ->
+ {
+ service.execute(count, ch, buffer);
+
+ buffer.append(' ');
+
+ service.execute(count + 1, Character.toUpperCase(ch), buffer);
+ };
+
+ MiddleService next = (count, ch, buffer) ->
+ {
+ for (int i = 0; i < count; i++)
+ {
+ buffer.append(ch);
+ }
+ };
+
+ BridgeBuilder<MiddleService, MiddleFilter> builder = new
BridgeBuilder<>(logger, MiddleService.class,
+ MiddleFilter.class, proxyFactory);
+
+ MiddleService bridge = builder.instantiateBridge(next, filter);
+
+ StringBuilder buffer = new StringBuilder("CODE: ");
+
+ bridge.execute(3, 'a', buffer);
+
+ assertEquals("CODE: aaa AAAA", buffer.toString());
+ }
+}
diff --git
a/tapestry-ioc/src/test/java/org/apache/tapestry5/ioc/internal/services/PipelineBuilderImplTest.java
b/tapestry-ioc/src/test/java/org/apache/tapestry5/ioc/internal/services/PipelineBuilderImplTest.java
new file mode 100644
index 000000000..952abaace
--- /dev/null
+++
b/tapestry-ioc/src/test/java/org/apache/tapestry5/ioc/internal/services/PipelineBuilderImplTest.java
@@ -0,0 +1,241 @@
+// Copyright 2026 The Apache Software Foundation
+//
+// 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 org.apache.tapestry5.ioc.internal.services;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.tapestry5.beanmodel.services.PlasticProxyFactoryImpl;
+import org.apache.tapestry5.commons.services.PlasticProxyFactory;
+import org.apache.tapestry5.ioc.services.AssembledPipeline;
+import org.apache.tapestry5.ioc.services.PipelineBuilder;
+import org.apache.tapestry5.ioc.test.RecordingLogger;
+import org.apache.tapestry5.ioc.test.internal.services.StandardFilter;
+import org.apache.tapestry5.ioc.test.internal.services.StandardService;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.slf4j.event.Level;
+
+class PipelineBuilderImplTest
+{
+ private static final String STANDARD_SERVICE =
StandardService.class.getName();
+
+ private static final String STANDARD_FILTER =
StandardFilter.class.getName();
+
+ private static PlasticProxyFactory proxyFactory;
+
+ private PipelineBuilder builder;
+
+ private RecordingLogger logger;
+
+ @BeforeAll
+ static void createProxyFactory()
+ {
+ proxyFactory = new
PlasticProxyFactoryImpl(PipelineBuilderImplTest.class.getClassLoader(), null);
+ }
+
+ /**
+ * A fresh builder for each test, since a builder keeps track of the
pipelines it has assembled.
+ */
+ @BeforeEach
+ void createBuilder()
+ {
+ builder = new PipelineBuilderImpl(proxyFactory, new
DefaultImplementationBuilderImpl(proxyFactory));
+
+ logger = new RecordingLogger();
+ }
+
+ /**
+ * A filter that delegates to the next service, with a fixed {@code
toString()} so that the description of the
+ * assembled pipeline is predictable.
+ */
+ private static StandardFilter namedFilter(final String name)
+ {
+ return new StandardFilter()
+ {
+ @Override
+ public int run(int i, StandardService service)
+ {
+ return service.run(i);
+ }
+
+ @Override
+ public String toString()
+ {
+ return name;
+ }
+ };
+ }
+
+ /**
+ * A terminator with a fixed {@code toString()}, for the same reason.
+ */
+ private static StandardService namedTerminator(final String name)
+ {
+ return new StandardService()
+ {
+ @Override
+ public int run(int i)
+ {
+ return i;
+ }
+
+ @Override
+ public String toString()
+ {
+ return name;
+ }
+ };
+ }
+
+ @Test
+ void standardPipelineWithFilters()
+ {
+ StandardFilter subtracter = (i, service) -> service.run(i) - 2;
+
+ StandardFilter multiplier = (i, service) -> 2 * service.run(i);
+
+ StandardFilter adder = (i, service) -> service.run(i + 3);
+
+ StandardService terminator = i -> i;
+
+ StandardService pipeline = builder.build(logger,
StandardService.class, StandardFilter.class,
+ Arrays.asList(subtracter, multiplier, adder), terminator);
+
+ assertEquals(14, pipeline.run(5));
+ assertEquals(24, pipeline.run(10));
+ }
+
+ @Test
+ void aPipelineWithoutFiltersIsSimplyTheTerminator()
+ {
+ StandardService terminator = i -> i;
+
+ StandardService pipeline = builder.build(logger,
StandardService.class, StandardFilter.class,
+ Collections.emptyList(), terminator);
+
+ assertSame(terminator, pipeline);
+ }
+
+ @Test
+ void aPipelineWithNoFiltersAndNoTerminatorDoesNothing()
+ {
+ StandardService pipeline = builder.build(logger,
StandardService.class, StandardFilter.class,
+ Collections.emptyList());
+
+ assertEquals(0, pipeline.run(99));
+ }
+
+ @Test
+ void toStringOfPipelineDescribesTheWholeChain()
+ {
+ StandardService pipeline = builder.build(logger,
StandardService.class, StandardFilter.class,
+ Arrays.asList(namedFilter("FIRST"), namedFilter("SECOND")),
namedTerminator("TERMINATOR"));
+
+ assertEquals(String.format("<PipelineBridge from %s to %s>: FIRST ->
SECOND -> TERMINATOR", STANDARD_SERVICE,
+ STANDARD_FILTER), pipeline.toString());
+ }
+
+ @Test
+ void everyStepOfThePipelineDescribesItselfOnwards()
+ {
+ StandardService pipeline = builder.build(logger,
StandardService.class, StandardFilter.class,
+ Arrays.asList(namedFilter("FIRST"), namedFilter("SECOND")),
namedTerminator("TERMINATOR"));
+
+ Object secondStep = ((PipelineStep) pipeline).getPipelineNext();
+
+ assertEquals(String.format("<PipelineBridge from %s to %s>: SECOND ->
TERMINATOR", STANDARD_SERVICE,
+ STANDARD_FILTER), secondStep.toString());
+ }
+
+ @Test
+ void thePipelineExposesItsFiltersInOrder()
+ {
+ StandardFilter first = (i, service) -> service.run(i);
+
+ StandardFilter second = (i, service) -> service.run(i);
+
+ StandardService terminator = i -> i;
+
+ StandardService pipeline = builder.build(logger,
StandardService.class, StandardFilter.class,
+ Arrays.asList(first, second), terminator);
+
+ PipelineStep firstStep = (PipelineStep) pipeline;
+
+ assertSame(first, firstStep.getPipelineFilter());
+
+ PipelineStep secondStep = (PipelineStep) firstStep.getPipelineNext();
+
+ assertSame(second, secondStep.getPipelineFilter());
+ assertSame(terminator, secondStep.getPipelineNext());
+ }
+
+ @Test
+ void toStringOfPipelineEndsWithTheDefaultTerminator()
+ {
+ StandardService pipeline = builder.build(logger,
StandardService.class, StandardFilter.class,
+ Collections.singletonList(namedFilter("ONLY")));
+
+ assertEquals(String.format("<PipelineBridge from %s to %s>: ONLY ->
<NoOp %s>", STANDARD_SERVICE,
+ STANDARD_FILTER, STANDARD_SERVICE), pipeline.toString());
+ }
+
+ @Test
+ void assembledPipelinesAreTracked()
+ {
+ builder.build(logger, StandardService.class, StandardFilter.class,
+ Arrays.asList(namedFilter("FIRST"), namedFilter("SECOND")),
namedTerminator("TERMINATOR"));
+
+ List<AssembledPipeline> pipelines = builder.getAssembledPipelines();
+
+ assertEquals(1, pipelines.size());
+
+ AssembledPipeline pipeline = pipelines.get(0);
+
+ assertEquals(StandardService.class, pipeline.getServiceInterface());
+ assertEquals(StandardFilter.class, pipeline.getFilterInterface());
+ assertEquals(Arrays.asList("FIRST", "SECOND"), pipeline.getFilters());
+ assertEquals("TERMINATOR", pipeline.getTerminator());
+
+ assertEquals(String.format("<PipelineBridge from %s to %s>: FIRST ->
SECOND -> TERMINATOR", STANDARD_SERVICE,
+ STANDARD_FILTER), pipeline.toString());
+ }
+
+ @Test
+ void aPipelineWithoutFiltersIsTrackedAsWell()
+ {
+ builder.build(logger, StandardService.class, StandardFilter.class,
Collections.emptyList(),
+ namedTerminator("TERMINATOR"));
+
+ AssembledPipeline pipeline = builder.getAssembledPipelines().get(0);
+
+ assertTrue(pipeline.getFilters().isEmpty());
+ assertEquals("TERMINATOR", pipeline.getTerminator());
+ }
+
+ @Test
+ void theAssembledPipelineIsLoggedAtDebugLevel()
+ {
+ StandardService pipeline = builder.build(logger,
StandardService.class, StandardFilter.class,
+ Collections.singletonList(namedFilter("ONLY")));
+
+ assertEquals(Collections.singletonList("Assembled pipeline: " +
pipeline), logger.getMessages(Level.DEBUG));
+ }
+}
diff --git
a/tapestry-ioc/src/test/java/org/apache/tapestry5/ioc/test/RecordingLogger.java
b/tapestry-ioc/src/test/java/org/apache/tapestry5/ioc/test/RecordingLogger.java
new file mode 100644
index 000000000..4103e87ef
--- /dev/null
+++
b/tapestry-ioc/src/test/java/org/apache/tapestry5/ioc/test/RecordingLogger.java
@@ -0,0 +1,177 @@
+// Copyright 2026 The Apache Software Foundation
+//
+// 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 org.apache.tapestry5.ioc.test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.slf4j.Marker;
+import org.slf4j.event.Level;
+import org.slf4j.helpers.AbstractLogger;
+import org.slf4j.helpers.MessageFormatter;
+
+/**
+ * A Logger that records every logging call, for tests that need to assert
what was logged. Every level is
+ * enabled, so nothing is filtered out before it can be recorded.
+ */
+public class RecordingLogger extends AbstractLogger
+{
+ private static final long serialVersionUID = 1L;
+
+ private final List<Event> events = new ArrayList<>();
+
+ /**
+ * A single recorded logging call, with its message already formatted.
+ */
+ public static final class Event
+ {
+ private final Level level;
+
+ private final String message;
+
+ Event(Level level, String message)
+ {
+ this.level = level;
+ this.message = message;
+ }
+
+ public Level getLevel()
+ {
+ return level;
+ }
+
+ public String getMessage()
+ {
+ return message;
+ }
+
+ @Override
+ public String toString()
+ {
+ return level + " " + message;
+ }
+ }
+
+ public RecordingLogger()
+ {
+ this.name = RecordingLogger.class.getName();
+ }
+
+ /**
+ * All recorded events, in the order they were logged.
+ */
+ public List<Event> getEvents()
+ {
+ return new ArrayList<>(events);
+ }
+
+ /**
+ * The formatted messages recorded at the given level, in the order they
were logged.
+ */
+ public List<String> getMessages(Level level)
+ {
+ List<String> result = new ArrayList<>();
+
+ for (Event event : events)
+ {
+ if (event.getLevel() == level)
+ {
+ result.add(event.getMessage());
+ }
+ }
+
+ return result;
+ }
+
+ public void clear()
+ {
+ events.clear();
+ }
+
+ @Override
+ protected String getFullyQualifiedCallerName()
+ {
+ return null;
+ }
+
+ @Override
+ protected void handleNormalizedLoggingCall(Level level, Marker marker,
String messagePattern, Object[] arguments,
+ Throwable throwable)
+ {
+ String message = arguments == null ? messagePattern :
MessageFormatter.basicArrayFormat(messagePattern,
+ arguments);
+
+ events.add(new Event(level, message));
+ }
+
+ @Override
+ public boolean isTraceEnabled()
+ {
+ return true;
+ }
+
+ @Override
+ public boolean isTraceEnabled(Marker marker)
+ {
+ return true;
+ }
+
+ @Override
+ public boolean isDebugEnabled()
+ {
+ return true;
+ }
+
+ @Override
+ public boolean isDebugEnabled(Marker marker)
+ {
+ return true;
+ }
+
+ @Override
+ public boolean isInfoEnabled()
+ {
+ return true;
+ }
+
+ @Override
+ public boolean isInfoEnabled(Marker marker)
+ {
+ return true;
+ }
+
+ @Override
+ public boolean isWarnEnabled()
+ {
+ return true;
+ }
+
+ @Override
+ public boolean isWarnEnabled(Marker marker)
+ {
+ return true;
+ }
+
+ @Override
+ public boolean isErrorEnabled()
+ {
+ return true;
+ }
+
+ @Override
+ public boolean isErrorEnabled(Marker marker)
+ {
+ return true;
+ }
+}