function) throws IOException {
+ IOException exception = null;
+ try {
+ function.apply(primary);
+ } catch (IOException e) {
+ exception = e;
+ }
+ for (OutputStream secondary : secondaries) {
+ try {
+ function.apply(secondary);
+ } catch (IOException e) {
+ if (exception == null) {
+ exception = e;
+ } else {
+ exception.addSuppressed(e);
+ }
+ }
+ }
+ if (exception != null) {
+ throw exception;
+ }
+ }
+}
diff --git a/src/main/resources/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfiguration/config.jelly b/src/main/resources/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfiguration/config.jelly
new file mode 100644
index 00000000..cace6a24
--- /dev/null
+++ b/src/main/resources/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfiguration/config.jelly
@@ -0,0 +1,12 @@
+
+
+
+
+ ${%description(descriptor.getDefaultFactoryDescriptor().getDisplayName(), descriptor.getDefaultFactoryPlugin())}
+
+
+
+
diff --git a/src/main/resources/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfiguration/config.properties b/src/main/resources/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfiguration/config.properties
new file mode 100644
index 00000000..321c7e63
--- /dev/null
+++ b/src/main/resources/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfiguration/config.properties
@@ -0,0 +1 @@
+description=Specify the Pipeline logger. The default logger is "{0}" (from {1}).
diff --git a/src/main/resources/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageFactory/config.jelly b/src/main/resources/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageFactory/config.jelly
new file mode 100644
index 00000000..bee84b7a
--- /dev/null
+++ b/src/main/resources/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageFactory/config.jelly
@@ -0,0 +1,16 @@
+
+
+
+
+ ${%description}
+
+
+
+
+
diff --git a/src/main/resources/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageFactory/config.properties b/src/main/resources/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageFactory/config.properties
new file mode 100644
index 00000000..76761add
--- /dev/null
+++ b/src/main/resources/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageFactory/config.properties
@@ -0,0 +1 @@
+description=Specify the primary and secondary Pipeline logger.
diff --git a/src/main/resources/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageFactory/help-primary.html b/src/main/resources/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageFactory/help-primary.html
new file mode 100644
index 00000000..e67da572
--- /dev/null
+++ b/src/main/resources/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageFactory/help-primary.html
@@ -0,0 +1,3 @@
+
+ The primary logger is used to read and write Pipeline build logs.
+
\ No newline at end of file
diff --git a/src/main/resources/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageFactory/help-secondary.html b/src/main/resources/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageFactory/help-secondary.html
new file mode 100644
index 00000000..c0c1cf35
--- /dev/null
+++ b/src/main/resources/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageFactory/help-secondary.html
@@ -0,0 +1,3 @@
+
+ If a secondary logger is selected, Pipeline build logs are duplicated as they are written and sent to this logger. This logger is never used to read logs.
+
\ No newline at end of file
diff --git a/src/test/java/org/jenkinsci/plugins/workflow/log/LogStorageTestBase.java b/src/test/java/org/jenkinsci/plugins/workflow/log/LogStorageTestBase.java
index a8cabbb8..a235eb71 100644
--- a/src/test/java/org/jenkinsci/plugins/workflow/log/LogStorageTestBase.java
+++ b/src/test/java/org/jenkinsci/plugins/workflow/log/LogStorageTestBase.java
@@ -230,9 +230,10 @@ private static final class GC extends MasterToSlaveCallable {
*/
@Test public void mangledLines() throws Exception {
Random r = new Random();
- BiFunction thread = (c, l) -> new Thread(() -> {
+ BiFunction thread = (alphabet, l) -> new Thread(() -> {
+ var len = alphabet.length();
for (int i = 0; i < 1000; i++) {
- l.getLogger().print(c);
+ l.getLogger().print(alphabet.charAt(i % len));
if (r.nextDouble() < 0.1) {
l.getLogger().println();
}
@@ -247,9 +248,9 @@ private static final class GC extends MasterToSlaveCallable {
});
List threads = new ArrayList<>();
LogStorage ls = createStorage();
- threads.add(thread.apply('.', ls.overallListener()));
- threads.add(thread.apply('1', ls.nodeListener(new MockNode("1"))));
- threads.add(thread.apply('2', ls.nodeListener(new MockNode("2"))));
+ threads.add(thread.apply("0123456789", ls.overallListener()));
+ threads.add(thread.apply("abcdefghijklmnopqrstuvwxyz", ls.nodeListener(new MockNode("1"))));
+ threads.add(thread.apply("ABCDEFGHIJKLMNOPQRSTUVWXYZ", ls.nodeListener(new MockNode("2"))));
threads.forEach(Thread::start);
threads.forEach(t -> {
try {
@@ -271,6 +272,7 @@ private static final class GC extends MasterToSlaveCallable {
// assertLength("2", pos);
// assertStepLog("2", pos, "", true);
text("2").writeRawLogTo(0, new NullOutputStream());
+ close(ls.overallListener());
}
@SuppressWarnings("deprecation")
diff --git a/src/test/java/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfigurationJCasCTest.java b/src/test/java/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfigurationJCasCTest.java
new file mode 100644
index 00000000..1838192a
--- /dev/null
+++ b/src/test/java/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfigurationJCasCTest.java
@@ -0,0 +1,174 @@
+package org.jenkinsci.plugins.workflow.log.configuration;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.instanceOf;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.not;
+import static org.hamcrest.Matchers.nullValue;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+import io.jenkins.plugins.casc.ConfiguratorException;
+import io.jenkins.plugins.casc.misc.ConfiguredWithCode;
+import io.jenkins.plugins.casc.misc.JenkinsConfiguredWithCodeRule;
+import java.io.File;
+import org.htmlunit.html.HtmlForm;
+import org.jenkinsci.Symbol;
+import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition;
+import org.jenkinsci.plugins.workflow.flow.FlowExecutionOwner;
+import org.jenkinsci.plugins.workflow.job.WorkflowJob;
+import org.jenkinsci.plugins.workflow.log.BrokenLogStorage;
+import org.jenkinsci.plugins.workflow.log.FileLogStorage;
+import org.jenkinsci.plugins.workflow.log.LogStorage;
+import org.jenkinsci.plugins.workflow.log.LogStorageFactory;
+import org.jenkinsci.plugins.workflow.log.LogStorageFactoryDescriptor;
+import org.jenkinsci.plugins.workflow.log.configuration.mock.LogStorageFactoryMock1;
+import org.jenkinsci.plugins.workflow.log.configuration.mock.LogStorageFactoryMock2;
+import org.jenkinsci.plugins.workflow.log.tee.RemoteCustomFileLogStorage;
+import org.jenkinsci.plugins.workflow.log.tee.TeeLogStorage;
+import org.jenkinsci.plugins.workflow.log.tee.TeeLogStorageFactory;
+import org.junit.Rule;
+import org.junit.Test;
+import org.jvnet.hudson.test.TestExtension;
+import org.kohsuke.stapler.DataBoundConstructor;
+
+public class PipelineLoggingGlobalConfigurationJCasCTest {
+
+ @Rule
+ public JenkinsConfiguredWithCodeRule r = new JenkinsConfiguredWithCodeRule();
+
+ @Test
+ public void default_factory() throws Throwable {
+ PipelineLoggingGlobalConfiguration config = PipelineLoggingGlobalConfiguration.get();
+ assertThat(config.getFactory(), nullValue());
+
+ // check build happens with the default file log storage
+ WorkflowJob workflowJob = r.createProject(WorkflowJob.class);
+ workflowJob.setDefinition(new CpsFlowDefinition("echo 'Hello World'", true));
+
+ r.buildAndAssertSuccess(workflowJob);
+ assertThat(LogStorage.of(workflowJob.getLastBuild().asFlowExecutionOwner()), instanceOf(FileLogStorage.class));
+
+ checkNoPipelineLoggingCasCConfiguration();
+ }
+
+ @Test
+ public void custom_default_factory() throws Throwable {
+ PipelineLoggingGlobalConfiguration config = PipelineLoggingGlobalConfiguration.get();
+ assertThat(config.getFactory(), nullValue());
+
+ // check build happens with the default file log storage
+ WorkflowJob workflowJob = r.createProject(WorkflowJob.class);
+ workflowJob.setDefinition(new CpsFlowDefinition("echo 'Hello World'", true));
+
+ r.buildAndAssertSuccess(workflowJob);
+ assertThat(
+ LogStorage.of(workflowJob.getLastBuild().asFlowExecutionOwner()),
+ instanceOf(RemoteCustomFileLogStorage.class));
+
+ checkNoPipelineLoggingCasCConfiguration();
+ }
+
+ @Test
+ public void custom_default_factory_ui() throws Throwable {
+ HtmlForm form = form = r.createWebClient().goTo("configure").getFormByName("config");
+ // not sure if there's a simpler way to get the select, as no `name` or `id` attribute is available
+ var selectedText =
+ form.getFirstByXPath("//*[@id='pipeline-logging']/../descendant::select/option[@selected]/text()");
+ assertThat(selectedText, nullValue());
+ var description = form.getFirstByXPath("//*[@id='pipeline-logging']/../descendant::div[@colspan]/text()");
+ assertThat(description.toString(), containsString("My custom log"));
+ checkNoPipelineLoggingCasCConfiguration();
+ }
+
+ @Test
+ @ConfiguredWithCode("jcasc_smokes.yaml")
+ public void smokes() throws Throwable {
+ PipelineLoggingGlobalConfiguration config = PipelineLoggingGlobalConfiguration.get();
+ assertThat(config.getFactory(), instanceOf(TeeLogStorageFactory.class));
+ var factory = (TeeLogStorageFactory) config.getFactory();
+ assertThat(factory.getPrimary(), instanceOf(LogStorageFactoryMock1.class));
+ assertThat(factory.getSecondary(), instanceOf(LogStorageFactoryMock2.class));
+
+ WorkflowJob workflowJob = r.createProject(WorkflowJob.class);
+ workflowJob.setDefinition(new CpsFlowDefinition("echo 'Hello World'", true));
+
+ r.buildAndAssertSuccess(workflowJob);
+ assertThat(LogStorage.of(workflowJob.getLastBuild().asFlowExecutionOwner()), instanceOf(TeeLogStorage.class));
+
+ String content = r.exportToString(true);
+ assertThat(content, containsString("pipelineLogging"));
+ assertThat(content, containsString("tee"));
+ assertThat(content, containsString("logMock1"));
+ assertThat(content, containsString("logMock2"));
+ }
+
+ @Test
+ @ConfiguredWithCode(
+ value = "jcasc_primary-only.yaml",
+ expected = ConfiguratorException.class,
+ message =
+ "Arguments: [org.jenkinsci.plugins.workflow.log.configuration.mock.LogStorageFactoryMock1, null].\n Expected Parameters: primary org.jenkinsci.plugins.workflow.log.LogStorageFactory, secondary org.jenkinsci.plugins.workflow.log.LogStorageFactory")
+ public void primary_only() throws Throwable {}
+
+ @Test
+ @ConfiguredWithCode(
+ value = "jcasc_secondary-only.yaml",
+ expected = ConfiguratorException.class,
+ message =
+ "Arguments: [null, org.jenkinsci.plugins.workflow.log.configuration.mock.LogStorageFactoryMock2].\n Expected Parameters: primary org.jenkinsci.plugins.workflow.log.LogStorageFactory, secondary org.jenkinsci.plugins.workflow.log.LogStorageFactory")
+ public void secondary_only() throws Throwable {}
+
+ @Test
+ @ConfiguredWithCode(
+ value = "jcasc_empty.yaml",
+ expected = ConfiguratorException.class,
+ message =
+ "Arguments: [null, null].\n Expected Parameters: primary org.jenkinsci.plugins.workflow.log.LogStorageFactory, secondary org.jenkinsci.plugins.workflow.log.LogStorageFactory")
+ public void empty() throws Throwable {}
+
+ @Test
+ @ConfiguredWithCode(
+ value = "jcasc_duplicate.yaml",
+ expected = ConfiguratorException.class,
+ message =
+ "Arguments: [org.jenkinsci.plugins.workflow.log.configuration.mock.LogStorageFactoryMock1, org.jenkinsci.plugins.workflow.log.configuration.mock.LogStorageFactoryMock1].\n Expected Parameters: primary org.jenkinsci.plugins.workflow.log.LogStorageFactory, secondary org.jenkinsci.plugins.workflow.log.LogStorageFactory")
+ public void duplicate() throws Throwable {}
+
+ private void checkNoPipelineLoggingCasCConfiguration() throws Exception {
+ r.configRoundtrip();
+ // check exported CasC
+ String content = r.exportToString(true);
+ assertThat(content, not(containsString("pipelineLogging")));
+ }
+
+ public static class LogStorageFactoryCustom implements LogStorageFactory {
+ @DataBoundConstructor
+ public LogStorageFactoryCustom() {}
+
+ @Override
+ public LogStorage forBuild(@NonNull FlowExecutionOwner b) {
+ try {
+ File file = new File(b.getRootDir(), "custom-log");
+ return RemoteCustomFileLogStorage.forFile(file);
+ } catch (Exception x) {
+ return new BrokenLogStorage(x);
+ }
+ }
+
+ @TestExtension({"custom_default_factory", "custom_default_factory_ui"})
+ @Symbol("logCustom")
+ public static final class DescriptorImpl extends LogStorageFactoryDescriptor {
+ @NonNull
+ @Override
+ public String getDisplayName() {
+ return "My custom log";
+ }
+
+ @Override
+ public LogStorageFactory getDefaultInstance() {
+ return new LogStorageFactoryCustom();
+ }
+ }
+ }
+}
diff --git a/src/test/java/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfigurationJCascRoundTripTest.java b/src/test/java/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfigurationJCascRoundTripTest.java
new file mode 100644
index 00000000..4cd836fb
--- /dev/null
+++ b/src/test/java/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfigurationJCascRoundTripTest.java
@@ -0,0 +1,32 @@
+package org.jenkinsci.plugins.workflow.log.configuration;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.instanceOf;
+
+import io.jenkins.plugins.casc.misc.junit.jupiter.AbstractRoundTripTest;
+import org.jenkinsci.plugins.workflow.log.configuration.mock.LogStorageFactoryMock1;
+import org.jenkinsci.plugins.workflow.log.configuration.mock.LogStorageFactoryMock2;
+import org.jenkinsci.plugins.workflow.log.tee.TeeLogStorageFactory;
+import org.jvnet.hudson.test.JenkinsRule;
+
+public class PipelineLoggingGlobalConfigurationJCascRoundTripTest extends AbstractRoundTripTest {
+
+ @Override
+ protected String configResource() {
+ return "jcasc_smokes.yaml";
+ }
+
+ @Override
+ protected void assertConfiguredAsExpected(JenkinsRule j, String configContent) {
+ PipelineLoggingGlobalConfiguration config = PipelineLoggingGlobalConfiguration.get();
+ assertThat(config.getFactory(), instanceOf(TeeLogStorageFactory.class));
+ var factory = (TeeLogStorageFactory) config.getFactory();
+ assertThat(factory.getPrimary(), instanceOf(LogStorageFactoryMock1.class));
+ assertThat(factory.getSecondary(), instanceOf(LogStorageFactoryMock2.class));
+ }
+
+ @Override
+ protected String stringInLogExpected() {
+ return "pipelineLogging";
+ }
+}
diff --git a/src/test/java/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfigurationTest.java b/src/test/java/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfigurationTest.java
new file mode 100644
index 00000000..104e7221
--- /dev/null
+++ b/src/test/java/org/jenkinsci/plugins/workflow/log/configuration/PipelineLoggingGlobalConfigurationTest.java
@@ -0,0 +1,145 @@
+package org.jenkinsci.plugins.workflow.log.configuration;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.instanceOf;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.nullValue;
+import static org.junit.Assert.assertThrows;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+import java.io.File;
+import org.jenkinsci.Symbol;
+import org.jenkinsci.plugins.workflow.flow.FlowExecutionOwner;
+import org.jenkinsci.plugins.workflow.log.BrokenLogStorage;
+import org.jenkinsci.plugins.workflow.log.FileLogStorageFactory;
+import org.jenkinsci.plugins.workflow.log.LogStorage;
+import org.jenkinsci.plugins.workflow.log.LogStorageFactory;
+import org.jenkinsci.plugins.workflow.log.LogStorageFactoryDescriptor;
+import org.jenkinsci.plugins.workflow.log.configuration.mock.LogStorageFactoryMock1;
+import org.jenkinsci.plugins.workflow.log.configuration.mock.LogStorageFactoryMock2;
+import org.jenkinsci.plugins.workflow.log.tee.RemoteCustomFileLogStorage;
+import org.jenkinsci.plugins.workflow.log.tee.TeeLogStorageFactory;
+import org.junit.Rule;
+import org.junit.Test;
+import org.jvnet.hudson.test.JenkinsSessionRule;
+import org.jvnet.hudson.test.TestExtension;
+import org.kohsuke.stapler.DataBoundConstructor;
+
+public class PipelineLoggingGlobalConfigurationTest {
+
+ @Rule
+ public JenkinsSessionRule sessions = new JenkinsSessionRule();
+
+ @Test
+ public void default_factory() throws Throwable {
+ sessions.then(r -> {
+ assertThat(PipelineLoggingGlobalConfiguration.get().getFactory(), nullValue());
+ assertThat(
+ PipelineLoggingGlobalConfiguration.get().getFactoryOrDefault(),
+ instanceOf(FileLogStorageFactory.class));
+ r.configRoundtrip();
+ });
+ sessions.then(r -> {
+ assertThat(PipelineLoggingGlobalConfiguration.get().getFactory(), nullValue());
+ assertThat(
+ PipelineLoggingGlobalConfiguration.get().getFactoryOrDefault(),
+ instanceOf(FileLogStorageFactory.class));
+ });
+ }
+
+ @Test
+ public void custom_default_factory() throws Throwable {
+ sessions.then(r -> {
+ assertThat(PipelineLoggingGlobalConfiguration.get().getFactory(), nullValue());
+ assertThat(
+ PipelineLoggingGlobalConfiguration.get().getFactoryOrDefault(),
+ instanceOf(LogStorageFactoryCustom.class));
+ r.configRoundtrip();
+ });
+ sessions.then(r -> {
+ assertThat(PipelineLoggingGlobalConfiguration.get().getFactory(), nullValue());
+ assertThat(
+ PipelineLoggingGlobalConfiguration.get().getFactoryOrDefault(),
+ instanceOf(LogStorageFactoryCustom.class));
+ });
+ }
+
+ @Test
+ public void teeLogStorageFactory() throws Throwable {
+ sessions.then(r -> {
+ TeeLogStorageFactory factory =
+ new TeeLogStorageFactory(new LogStorageFactoryMock1(), new LogStorageFactoryMock2());
+ PipelineLoggingGlobalConfiguration.get().setFactory(factory);
+ r.configRoundtrip();
+ });
+ sessions.then(r -> {
+ var configuration = PipelineLoggingGlobalConfiguration.get();
+ assertThat(configuration.getFactory(), instanceOf(TeeLogStorageFactory.class));
+ var factory = (TeeLogStorageFactory) configuration.getFactory();
+ assertThat(factory.getPrimary(), instanceOf(LogStorageFactoryMock1.class));
+ assertThat(factory.getSecondary(), instanceOf(LogStorageFactoryMock2.class));
+ });
+ }
+
+ @Test
+ public void teeLogStorageFactory_primary_null() throws Throwable {
+ sessions.then(r -> {
+ IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> {
+ new TeeLogStorageFactory(null, new LogStorageFactoryMock2());
+ });
+ assertThat(exception.getMessage(), is("Primary Pipeline logger cannot be null"));
+ r.configRoundtrip();
+ });
+ sessions.then(r -> {
+ var configuration = PipelineLoggingGlobalConfiguration.get();
+ assertThat(configuration.getFactory(), nullValue());
+ assertThat(configuration.getFactoryOrDefault(), instanceOf(FileLogStorageFactory.class));
+ });
+ }
+
+ @Test
+ public void teeLogStorageFactory_secondary_null() throws Throwable {
+ sessions.then(r -> {
+ IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> {
+ new TeeLogStorageFactory(new LogStorageFactoryMock1(), null);
+ });
+ assertThat(exception.getMessage(), is("Secondary Pipeline logger cannot be null"));
+ r.configRoundtrip();
+ });
+ sessions.then(r -> {
+ var configuration = PipelineLoggingGlobalConfiguration.get();
+ assertThat(configuration.getFactory(), nullValue());
+ assertThat(configuration.getFactoryOrDefault(), instanceOf(FileLogStorageFactory.class));
+ });
+ }
+
+ public static class LogStorageFactoryCustom implements LogStorageFactory {
+ @DataBoundConstructor
+ public LogStorageFactoryCustom() {}
+
+ @Override
+ public LogStorage forBuild(@NonNull FlowExecutionOwner b) {
+ try {
+ File file = new File(b.getRootDir(), "custom-log");
+ return RemoteCustomFileLogStorage.forFile(file);
+ } catch (Exception x) {
+ return new BrokenLogStorage(x);
+ }
+ }
+
+ @TestExtension("custom_default_factory")
+ @Symbol("logCustom")
+ public static final class DescriptorImpl extends LogStorageFactoryDescriptor {
+ @NonNull
+ @Override
+ public String getDisplayName() {
+ return "My custom log";
+ }
+
+ @Override
+ public LogStorageFactory getDefaultInstance() {
+ return new LogStorageFactoryCustom();
+ }
+ }
+ }
+}
diff --git a/src/test/java/org/jenkinsci/plugins/workflow/log/configuration/mock/LogStorageFactoryMock1.java b/src/test/java/org/jenkinsci/plugins/workflow/log/configuration/mock/LogStorageFactoryMock1.java
new file mode 100644
index 00000000..90fbb00e
--- /dev/null
+++ b/src/test/java/org/jenkinsci/plugins/workflow/log/configuration/mock/LogStorageFactoryMock1.java
@@ -0,0 +1,40 @@
+package org.jenkinsci.plugins.workflow.log.configuration.mock;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+import hudson.Extension;
+import java.io.File;
+import org.jenkinsci.Symbol;
+import org.jenkinsci.plugins.workflow.flow.FlowExecutionOwner;
+import org.jenkinsci.plugins.workflow.log.BrokenLogStorage;
+import org.jenkinsci.plugins.workflow.log.FileLogStorage;
+import org.jenkinsci.plugins.workflow.log.LogStorage;
+import org.jenkinsci.plugins.workflow.log.LogStorageFactory;
+import org.jenkinsci.plugins.workflow.log.LogStorageFactoryDescriptor;
+import org.jenkinsci.plugins.workflow.log.tee.RemoteCustomFileLogStorage;
+import org.kohsuke.stapler.DataBoundConstructor;
+
+public class LogStorageFactoryMock1 implements LogStorageFactory {
+
+ @DataBoundConstructor
+ public LogStorageFactoryMock1() {}
+
+ @Override
+ public LogStorage forBuild(@NonNull FlowExecutionOwner b) {
+ try {
+ File file = new File(b.getRootDir(), "log-mock1");
+ return FileLogStorage.forFile(file);
+ } catch (Exception x) {
+ return new BrokenLogStorage(x);
+ }
+ }
+
+ @Extension(ordinal = -2)
+ @Symbol("logMock1")
+ public static final class DescriptorImpl extends LogStorageFactoryDescriptor {
+ @NonNull
+ @Override
+ public String getDisplayName() {
+ return "A mock Pipeline logger";
+ }
+ }
+}
diff --git a/src/test/java/org/jenkinsci/plugins/workflow/log/configuration/mock/LogStorageFactoryMock2.java b/src/test/java/org/jenkinsci/plugins/workflow/log/configuration/mock/LogStorageFactoryMock2.java
new file mode 100644
index 00000000..ddd88b24
--- /dev/null
+++ b/src/test/java/org/jenkinsci/plugins/workflow/log/configuration/mock/LogStorageFactoryMock2.java
@@ -0,0 +1,39 @@
+package org.jenkinsci.plugins.workflow.log.configuration.mock;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+import hudson.Extension;
+import java.io.File;
+import org.jenkinsci.Symbol;
+import org.jenkinsci.plugins.workflow.flow.FlowExecutionOwner;
+import org.jenkinsci.plugins.workflow.log.BrokenLogStorage;
+import org.jenkinsci.plugins.workflow.log.FileLogStorage;
+import org.jenkinsci.plugins.workflow.log.LogStorage;
+import org.jenkinsci.plugins.workflow.log.LogStorageFactory;
+import org.jenkinsci.plugins.workflow.log.LogStorageFactoryDescriptor;
+import org.kohsuke.stapler.DataBoundConstructor;
+
+public class LogStorageFactoryMock2 implements LogStorageFactory {
+
+ @DataBoundConstructor
+ public LogStorageFactoryMock2() {}
+
+ @Override
+ public LogStorage forBuild(@NonNull FlowExecutionOwner b) {
+ try {
+ File file = new File(b.getRootDir(), "log-mock2");
+ return FileLogStorage.forFile(file);
+ } catch (Exception x) {
+ return new BrokenLogStorage(x);
+ }
+ }
+
+ @Extension(ordinal = -1)
+ @Symbol("logMock2")
+ public static final class DescriptorImpl extends LogStorageFactoryDescriptor {
+ @NonNull
+ @Override
+ public String getDisplayName() {
+ return "Another mock Pipeline logger";
+ }
+ }
+}
diff --git a/src/test/java/org/jenkinsci/plugins/workflow/log/tee/RemoteCustomFileLogStorage.java b/src/test/java/org/jenkinsci/plugins/workflow/log/tee/RemoteCustomFileLogStorage.java
new file mode 100644
index 00000000..c260ea37
--- /dev/null
+++ b/src/test/java/org/jenkinsci/plugins/workflow/log/tee/RemoteCustomFileLogStorage.java
@@ -0,0 +1,184 @@
+package org.jenkinsci.plugins.workflow.log.tee;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+import hudson.console.AnnotatedLargeText;
+import hudson.console.LineTransformationOutputStream;
+import hudson.model.BuildListener;
+import hudson.model.TaskListener;
+import hudson.remoting.RemoteOutputStream;
+import java.io.Closeable;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.logging.Logger;
+import org.jenkinsci.plugins.workflow.flow.FlowExecutionOwner;
+import org.jenkinsci.plugins.workflow.graph.FlowNode;
+import org.jenkinsci.plugins.workflow.log.LogStorage;
+import org.jenkinsci.plugins.workflow.log.OutputStreamTaskListener;
+import org.jenkinsci.remoting.SerializableOnlyOverRemoting;
+
+/**
+ * LogStorage acting as a FileLogStorage without the index.
+ * When serialized over remoting, transorms all the characters to uppercase.
+ */
+public class RemoteCustomFileLogStorage implements LogStorage {
+ private static final Logger LOGGER = Logger.getLogger(RemoteCustomFileLogStorage.class.getName());
+
+ private final File log;
+ private OutputStream out;
+ private OutputStreamSupplier supplier;
+
+ private static final Map openStorages =
+ Collections.synchronizedMap(new HashMap<>());
+
+ public static synchronized LogStorage forFile(File log) {
+ return forFile(log, null);
+ }
+
+ public static synchronized LogStorage forFile(File log, OutputStreamSupplier supplier) {
+ return openStorages.computeIfAbsent(log, key -> new RemoteCustomFileLogStorage(key, supplier));
+ }
+
+ private RemoteCustomFileLogStorage(File log) {
+ this(log, null);
+ }
+
+ @FunctionalInterface
+ public interface OutputStreamSupplier{
+ OutputStream apply() throws IOException;
+ }
+
+ private RemoteCustomFileLogStorage(File log, OutputStreamSupplier supplier) {
+ this.log = log;
+ this.supplier = supplier;
+ }
+
+ private synchronized void open() throws IOException {
+ if (this.supplier == null) {
+ this.out = new FileOutputStream(log, true);
+ return;
+ }
+ this.out = supplier.apply();
+ }
+
+ @Override
+ @NonNull
+ public BuildListener overallListener() throws IOException, InterruptedException {
+ return new MyListener(new Writer(null));
+ }
+
+ @Override
+ @NonNull
+ public TaskListener nodeListener(FlowNode node) throws IOException, InterruptedException {
+ // TODO: Does not actually handle step logs differently.
+ return new MyListener(new Writer(node.getId()));
+ }
+
+ private static final class MyListener extends OutputStreamTaskListener.Default implements BuildListener, Closeable {
+ private static final long serialVersionUID = 1;
+ private final OutputStream listenerOut;
+
+ public MyListener(OutputStream listenerOut) {
+ this.listenerOut = listenerOut;
+ }
+
+ @Override
+ @NonNull
+ public OutputStream getOutputStream() {
+ return listenerOut;
+ }
+
+ @Override
+ public void close() throws IOException {
+ getLogger().close();
+ }
+
+ private Object writeReplace() throws IOException {
+ return new MyListener(new UppercaseWriter(listenerOut));
+ }
+ }
+
+ private final class Writer extends OutputStream implements SerializableOnlyOverRemoting {
+ private final String node;
+
+ public Writer(String node) throws IOException {
+ this.node = node;
+ open();
+ }
+
+ @Override
+ public void write(int b) throws IOException {
+ synchronized (RemoteCustomFileLogStorage.this) {
+ out.write(b);
+ }
+ }
+
+ @Override
+ public void write(@NonNull byte[] b, int off, int len) throws IOException {
+ synchronized (RemoteCustomFileLogStorage.this) {
+ out.write(b, off, len);
+ }
+ }
+
+ @Override
+ public void flush() throws IOException {
+ out.flush();
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (node == null) {
+ openStorages.remove(log);
+ out.close();
+ }
+ }
+ }
+
+ private static final class UppercaseWriter extends LineTransformationOutputStream
+ implements SerializableOnlyOverRemoting {
+ private static final long serialVersionUID = 1L;
+ private final RemoteOutputStream out;
+
+ public UppercaseWriter(OutputStream out) {
+ this.out = new RemoteOutputStream(out);
+ }
+
+ @Override
+ protected void eol(byte[] b, int len) throws IOException {
+ String line = new String(b, 0, len, StandardCharsets.UTF_8);
+ var uppercaseLine = line.toUpperCase(Locale.ENGLISH);
+ var uppercaseBytes = uppercaseLine.getBytes(StandardCharsets.UTF_8);
+ out.write(uppercaseBytes);
+ }
+
+ @Override
+ public void flush() throws IOException {
+ out.flush();
+ }
+
+ @Override
+ public void close() throws IOException {
+ out.close();
+ }
+ }
+
+ @NonNull
+ @Override
+ public AnnotatedLargeText overallLog(
+ @NonNull FlowExecutionOwner.Executable build, boolean complete) {
+ return new AnnotatedLargeText<>(log, StandardCharsets.UTF_8, complete, build);
+ }
+
+ @NonNull
+ @Override
+ public AnnotatedLargeText stepLog(@NonNull FlowNode node, boolean complete) {
+ // TODO: Does not actually handle step logs differently.
+ return new AnnotatedLargeText<>(log, StandardCharsets.UTF_8, complete, node);
+ }
+}
diff --git a/src/test/java/org/jenkinsci/plugins/workflow/log/tee/RemoteCustomFileLogStorageFactory.java b/src/test/java/org/jenkinsci/plugins/workflow/log/tee/RemoteCustomFileLogStorageFactory.java
new file mode 100644
index 00000000..20052a44
--- /dev/null
+++ b/src/test/java/org/jenkinsci/plugins/workflow/log/tee/RemoteCustomFileLogStorageFactory.java
@@ -0,0 +1,38 @@
+package org.jenkinsci.plugins.workflow.log.tee;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+import hudson.Extension;
+import java.io.File;
+import org.jenkinsci.Symbol;
+import org.jenkinsci.plugins.workflow.flow.FlowExecutionOwner;
+import org.jenkinsci.plugins.workflow.log.BrokenLogStorage;
+import org.jenkinsci.plugins.workflow.log.LogStorage;
+import org.jenkinsci.plugins.workflow.log.LogStorageFactory;
+import org.jenkinsci.plugins.workflow.log.LogStorageFactoryDescriptor;
+import org.kohsuke.stapler.DataBoundConstructor;
+
+public class RemoteCustomFileLogStorageFactory implements LogStorageFactory {
+
+ @DataBoundConstructor
+ public RemoteCustomFileLogStorageFactory() {}
+
+ @Override
+ public LogStorage forBuild(@NonNull FlowExecutionOwner b) {
+ try {
+ File file = new File(b.getRootDir(), "custom-log");
+ return RemoteCustomFileLogStorage.forFile(file);
+ } catch (Exception x) {
+ return new BrokenLogStorage(x);
+ }
+ }
+
+ @Extension
+ @Symbol("customLog")
+ public static final class DescriptorImpl extends LogStorageFactoryDescriptor {
+ @NonNull
+ @Override
+ public String getDisplayName() {
+ return "Remote custom file logger";
+ }
+ }
+}
diff --git a/src/test/java/org/jenkinsci/plugins/workflow/log/tee/TeeBuildListenerTest.java b/src/test/java/org/jenkinsci/plugins/workflow/log/tee/TeeBuildListenerTest.java
new file mode 100644
index 00000000..b19e9900
--- /dev/null
+++ b/src/test/java/org/jenkinsci/plugins/workflow/log/tee/TeeBuildListenerTest.java
@@ -0,0 +1,242 @@
+package org.jenkinsci.plugins.workflow.log.tee;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.emptyString;
+import static org.hamcrest.Matchers.is;
+import static org.junit.Assert.fail;
+
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.file.Files;
+import org.jenkinsci.plugins.workflow.log.FileLogStorage;
+import org.junit.Before;
+import org.junit.ClassRule;
+import org.junit.Ignore;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.jvnet.hudson.test.JenkinsRule;
+import org.jvnet.hudson.test.LoggerRule;
+
+/**
+ * Tests related to potential exceptions when using TeeLogStorage.
+ */
+public class TeeBuildListenerTest {
+ @ClassRule
+ public static JenkinsRule r = new JenkinsRule();
+
+ @ClassRule
+ public static LoggerRule logging = new LoggerRule();
+
+ @Rule
+ public TemporaryFolder tmp = new TemporaryFolder();
+
+ private File fileLogStorageFileA;
+ private File fileLogStorageFileB;
+ private File remoteCustomFileLogStorageFile;
+ private static final String CONTENT = "Hello World";
+
+ @Before
+ public void before() throws Exception {
+ fileLogStorageFileA = tmp.newFile();
+ fileLogStorageFileB = tmp.newFile();
+ remoteCustomFileLogStorageFile = tmp.newFile();
+ }
+
+ /**
+ * Test {@link TeeOutputStream#write(int)}
+ */
+ @Test
+ public void primary_fails_write_char() throws Exception {
+ char content = 'a';
+ var ls = primaryFails(() -> new BufferedOutputStream(new FileOutputStream(remoteCustomFileLogStorageFile)) {
+ @Override
+ public void write(int b) throws IOException {
+ throw new IOException();
+ }
+ });
+ try (TeeBuildListener overall = (TeeBuildListener) ls.overallListener()) {
+ overall.getLogger().write(content);
+ } catch (IOException e) {
+ fail();
+ } finally {
+ assertCustomFileEmpty(String.valueOf(content));
+ }
+ }
+
+ /**
+ *Test {@link TeeOutputStream#write(byte[], int, int)}
+ */
+ @Test
+ public void primary_fails_write_string() throws Exception {
+ var ls = primaryFails(() -> new BufferedOutputStream(new FileOutputStream(remoteCustomFileLogStorageFile)) {
+ @Override
+ public void write(byte[] b, int off, int len) throws IOException {
+ throw new IOException();
+ }
+ });
+ try (TeeBuildListener overall = (TeeBuildListener) ls.overallListener()) {
+ overall.getLogger().print(CONTENT);
+ } catch (IOException e) {
+ fail();
+ } finally {
+ assertCustomFileEmpty(CONTENT);
+ }
+ }
+
+ /**
+ * Test {@link TeeOutputStream#flush()}
+ */
+ @Test
+ public void primary_fails_flush() throws Exception {
+ var ls = primaryFails(() -> new BufferedOutputStream(new FileOutputStream(remoteCustomFileLogStorageFile)) {
+ @Override
+ public void flush() throws IOException {
+ throw new IOException("Exception for test");
+ }
+ });
+
+ try (TeeBuildListener overall = (TeeBuildListener) ls.overallListener()) {
+ overall.getLogger().print(CONTENT);
+ } catch (IOException e) {
+ assertThat(e.getMessage(), is("Exception for test"));
+ } finally {
+ assertCustomFileEmpty(CONTENT);
+ }
+ }
+
+ /**
+ * Test {@link TeeOutputStream#close()}
+ */
+ @Test
+ @Ignore
+ public void primary_fails_close() throws Exception {
+ var ls = primaryFails(() -> new BufferedOutputStream(new FileOutputStream(remoteCustomFileLogStorageFile)) {
+ @Override
+ public void close() throws IOException {
+ throw new IOException("Exception for test");
+ }
+ });
+ try (TeeBuildListener overall = (TeeBuildListener) ls.overallListener()) {
+ overall.getLogger().print(CONTENT);
+ } catch (IOException e) {
+ assertThat(e.getMessage(), is("Exception for test"));
+ } finally {
+ assertCustomFileEmpty(CONTENT);
+ }
+ }
+
+ /**
+ * Test {@link TeeOutputStream#write(int)}
+ */
+ @Test
+ public void secondary_fails_write_char() throws Exception {
+ char content = 'a';
+ var ls = secondaryFails(() -> new BufferedOutputStream(new FileOutputStream(remoteCustomFileLogStorageFile)) {
+ @Override
+ public void write(int b) throws IOException {
+ throw new IOException();
+ }
+ });
+ try (TeeBuildListener overall = (TeeBuildListener) ls.overallListener()) {
+ overall.getLogger().write(content);
+ } catch (IOException e) {
+ fail();
+ } finally {
+ assertCustomFileEmpty(String.valueOf(content));
+ }
+ }
+
+ /**
+ *Test {@link TeeOutputStream#write(byte[], int, int)}
+ */
+ @Test
+ public void secondary_fails_write_string() throws Exception {
+ var ls = secondaryFails(() -> new BufferedOutputStream(new FileOutputStream(remoteCustomFileLogStorageFile)) {
+ @Override
+ public void write(byte[] b, int off, int len) throws IOException {
+ throw new IOException();
+ }
+ });
+ try (TeeBuildListener overall = (TeeBuildListener) ls.overallListener()) {
+ overall.getLogger().print(CONTENT);
+ } catch (IOException e) {
+ fail();
+ } finally {
+ assertCustomFileEmpty(CONTENT);
+ }
+ }
+
+ /**
+ * Test {@link TeeOutputStream#flush()}
+ */
+ @Test
+ public void secondary_fails_flush() throws Exception {
+ var ls = secondaryFails(() -> new BufferedOutputStream(new FileOutputStream(remoteCustomFileLogStorageFile)) {
+ @Override
+ public void flush() throws IOException {
+ throw new IOException("Exception for test");
+ }
+ });
+
+ try (TeeBuildListener overall = (TeeBuildListener) ls.overallListener()) {
+ overall.getLogger().print(CONTENT);
+ } catch (IOException e) {
+ assertThat(e.getMessage(), is("Exception for test"));
+ } finally {
+ assertCustomFileEmpty(CONTENT);
+ }
+ }
+
+ /**
+ * Test {@link TeeOutputStream#close()}
+ */
+ @Test
+ @Ignore
+ public void secondary_fails_close() throws Exception {
+ var ls = secondaryFails(() -> new BufferedOutputStream(new FileOutputStream(remoteCustomFileLogStorageFile)) {
+ @Override
+ public void close() throws IOException {
+ throw new IOException("Exception for test");
+ }
+ });
+
+ try (TeeBuildListener overall = (TeeBuildListener) ls.overallListener()) {
+ overall.getLogger().print(CONTENT);
+ } catch (IOException e) {
+ assertThat(e.getMessage(), is("Exception for test"));
+ } finally {
+ assertCustomFileEmpty(CONTENT);
+ }
+ }
+
+ private TeeLogStorage primaryFails(RemoteCustomFileLogStorage.OutputStreamSupplier failingOutputStream) {
+ return new TeeLogStorage(
+ RemoteCustomFileLogStorage.forFile(remoteCustomFileLogStorageFile, failingOutputStream),
+ FileLogStorage.forFile(fileLogStorageFileA),
+ FileLogStorage.forFile(fileLogStorageFileB));
+ }
+
+ private TeeLogStorage secondaryFails(RemoteCustomFileLogStorage.OutputStreamSupplier failingOutputStream) {
+ return new TeeLogStorage(
+ FileLogStorage.forFile(fileLogStorageFileA),
+ RemoteCustomFileLogStorage.forFile(remoteCustomFileLogStorageFile, failingOutputStream),
+ FileLogStorage.forFile(fileLogStorageFileB));
+ }
+
+ private void assertCustomFileEmpty(String content) throws IOException {
+ var remoteCustomFileLogStorageContent = getContent(remoteCustomFileLogStorageFile);
+ var fileLogStorageFileAContent = getContent(fileLogStorageFileA);
+ var fileLogStorageFileBContent = getContent(fileLogStorageFileB);
+
+ assertThat(remoteCustomFileLogStorageContent, emptyString());
+ assertThat(fileLogStorageFileAContent, is(content));
+ assertThat(fileLogStorageFileBContent, is(content));
+ }
+
+ private String getContent(File file) throws IOException {
+ return Files.readString(file.toPath());
+ }
+}
diff --git a/src/test/java/org/jenkinsci/plugins/workflow/log/tee/TeeLogStoragePipelineTest.java b/src/test/java/org/jenkinsci/plugins/workflow/log/tee/TeeLogStoragePipelineTest.java
new file mode 100644
index 00000000..22adbbbf
--- /dev/null
+++ b/src/test/java/org/jenkinsci/plugins/workflow/log/tee/TeeLogStoragePipelineTest.java
@@ -0,0 +1,49 @@
+package org.jenkinsci.plugins.workflow.log.tee;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.instanceOf;
+import static org.hamcrest.io.FileMatchers.anExistingFile;
+
+import java.io.File;
+import java.nio.file.Files;
+import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition;
+import org.jenkinsci.plugins.workflow.job.WorkflowJob;
+import org.jenkinsci.plugins.workflow.job.WorkflowRun;
+import org.jenkinsci.plugins.workflow.log.FileLogStorageFactory;
+import org.jenkinsci.plugins.workflow.log.LogStorage;
+import org.jenkinsci.plugins.workflow.log.configuration.PipelineLoggingGlobalConfiguration;
+import org.junit.Rule;
+import org.junit.Test;
+import org.jvnet.hudson.test.JenkinsRule;
+
+public class TeeLogStoragePipelineTest {
+
+ @Rule
+ public JenkinsRule j = new JenkinsRule();
+
+ @Test
+ public void smokes() throws Exception {
+ var storageFactory =
+ new TeeLogStorageFactory(new FileLogStorageFactory(), new RemoteCustomFileLogStorageFactory());
+ var config = PipelineLoggingGlobalConfiguration.get();
+ config.setFactory(storageFactory);
+
+ WorkflowJob workflowJob = j.createProject(WorkflowJob.class);
+ workflowJob.setDefinition(new CpsFlowDefinition("echo 'Hello World'", true));
+
+ WorkflowRun b = j.buildAndAssertSuccess(workflowJob);
+ assertThat(LogStorage.of(workflowJob.getLastBuild().asFlowExecutionOwner()), instanceOf(TeeLogStorage.class));
+
+ File logIndex = new File(b.getRootDir(), "log-index");
+ File log = new File(b.getRootDir(), "log");
+ File customLog = new File(b.getRootDir(), "custom-log");
+
+ assertThat(logIndex, anExistingFile());
+ assertThat(log, anExistingFile());
+ assertThat(customLog, anExistingFile());
+
+ assertThat(Files.readString(log.toPath()), containsString("Hello World"));
+ assertThat(Files.readString(customLog.toPath()), containsString("Hello World"));
+ }
+}
diff --git a/src/test/java/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageTest.java b/src/test/java/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageTest.java
new file mode 100644
index 00000000..707a3a2e
--- /dev/null
+++ b/src/test/java/org/jenkinsci/plugins/workflow/log/tee/TeeLogStorageTest.java
@@ -0,0 +1,107 @@
+package org.jenkinsci.plugins.workflow.log.tee;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalToIgnoringCase;
+import static org.hamcrest.Matchers.is;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.jenkinsci.plugins.workflow.log.FileLogStorage;
+import org.jenkinsci.plugins.workflow.log.LogStorage;
+import org.jenkinsci.plugins.workflow.log.LogStorageTestBase;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.rules.TemporaryFolder;
+
+public class TeeLogStorageTest extends LogStorageTestBase {
+
+ @Rule
+ public TemporaryFolder tmp = new TemporaryFolder();
+
+ private File primaryFile;
+ private File fileLogStorageFile;
+ private File remoteCustomFileLogStorageFile;
+ /**
+ * For the remoteCustomFileLogStorage, the logs are tranfromed in uppercase, meaning the case should be different
+ */
+ private boolean remoteCase = false;
+ /**
+ * For the mangled case we simply check the sequences are properly in order, we check all lowercase alphabet, uppercase alphabet and digits are in the correct seauence
+ */
+ private boolean mangledCase = false;
+
+ @Before
+ public void before() throws Exception {
+ primaryFile = tmp.newFile();
+ fileLogStorageFile = tmp.newFile();
+ remoteCustomFileLogStorageFile = tmp.newFile();
+ }
+
+ @Override
+ protected LogStorage createStorage() {
+ return new TeeLogStorage(
+ FileLogStorage.forFile(primaryFile),
+ FileLogStorage.forFile(fileLogStorageFile),
+ RemoteCustomFileLogStorage.forFile(remoteCustomFileLogStorageFile));
+ }
+
+ @Override
+ public void mangledLines() throws Exception {
+ mangledCase = true;
+ super.mangledLines();
+ }
+
+ @Override
+ public void remoting() throws Exception {
+ remoteCase = true;
+ super.remoting();
+ }
+
+ @After
+ public void additional_checks() throws IOException {
+ var primaryContent = getContent(primaryFile);
+ var fileLogStorageContent = getContent(fileLogStorageFile);
+ var remoteCustomFileLogStorageContent = getContent(remoteCustomFileLogStorageFile);
+
+ if (mangledCase) {
+ checkMangledLines(primaryContent, fileLogStorageContent, remoteCustomFileLogStorageContent);
+ return;
+ }
+
+ assertThat(fileLogStorageContent, is(primaryContent));
+ if (remoteCase) {
+ assertThat(remoteCustomFileLogStorageContent, equalToIgnoringCase(primaryContent));
+ } else {
+ assertThat(remoteCustomFileLogStorageContent, is(primaryContent));
+ }
+ }
+
+ private String getContent(File file) throws IOException {
+ return Files.readString(file.toPath());
+ }
+
+ private void checkMangledLines(
+ String primaryContent, String fileLogStorageContent, String remoteCustomFileLogStorageContent) {
+ for (Pattern pattern : List.of(Pattern.compile("[a-z]"), Pattern.compile("[A-Z]"), Pattern.compile("[0-9]"))) {
+ var primaryExtract = extractCharacters(pattern, primaryContent);
+ var fileLogStorageExtract = extractCharacters(pattern, fileLogStorageContent);
+ var remoteCustomFileLogStorageExtract = extractCharacters(pattern, remoteCustomFileLogStorageContent);
+ assertThat(primaryExtract, is(fileLogStorageExtract));
+ assertThat(primaryExtract, is(remoteCustomFileLogStorageExtract));
+ }
+ }
+
+ private String extractCharacters(Pattern pattern, String content) {
+ Matcher matcher = pattern.matcher(content);
+ StringBuilder sb = new StringBuilder();
+ while (matcher.find()) {
+ sb.append(matcher.group());
+ }
+ return sb.toString();
+ }
+}
diff --git a/src/test/java/org/jenkinsci/plugins/workflow/log/tee/TeeOutputStreamTest.java b/src/test/java/org/jenkinsci/plugins/workflow/log/tee/TeeOutputStreamTest.java
new file mode 100644
index 00000000..434d05d9
--- /dev/null
+++ b/src/test/java/org/jenkinsci/plugins/workflow/log/tee/TeeOutputStreamTest.java
@@ -0,0 +1,113 @@
+package org.jenkinsci.plugins.workflow.log.tee;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.junit.Assert.fail;
+
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.logging.Logger;
+import org.junit.Test;
+
+/**
+ * Tests related to potential exceptions when using TeeLogStorage.
+ */
+public class TeeOutputStreamTest {
+
+ private static final Logger LOGGER = Logger.getLogger(TeeOutputStreamTest.class.getName());
+
+ private File fileLogStorageFileA;
+ private File fileLogStorageFileB;
+ private File remoteCustomFileLogStorageFile;
+ private static final String CONTENT = "Hello World";
+
+ /**
+ * Test {@link TeeOutputStream#write(int)}
+ */
+ @Test
+ public void fails_write_char() throws Exception {
+ var out = multipleFails(new BufferedOutputStream(OutputStream.nullOutputStream()) {
+ @Override
+ public void write(int b) throws IOException {
+ throw new IOException("Exception for test");
+ }
+ });
+ try {
+ out.write(0);
+ fail();
+ } catch (IOException e) {
+ assertException(e);
+ }
+ }
+
+ /**
+ *Test {@link TeeOutputStream#write(byte[], int, int)}
+ */
+ @Test
+ public void fails_write_string() throws Exception {
+ var out = multipleFails(new BufferedOutputStream(OutputStream.nullOutputStream()) {
+ @Override
+ public void write(byte[] b, int off, int len) throws IOException {
+ throw new IOException("Exception for test");
+ }
+ });
+ try {
+ out.write(new byte[] {0}, 0, 1);
+ fail();
+ } catch (IOException e) {
+ assertException(e);
+ }
+ }
+
+ /**
+ * Test {@link TeeOutputStream#flush()}
+ */
+ @Test
+ public void fails_flush() throws Exception {
+ var out = multipleFails(new BufferedOutputStream(OutputStream.nullOutputStream()) {
+ @Override
+ public void flush() throws IOException {
+ throw new IOException("Exception for test");
+ }
+ });
+ try {
+ out.flush();
+ fail();
+ } catch (IOException e) {
+ assertException(e);
+ }
+ }
+
+ /**
+ * Test {@link TeeOutputStream#close()}
+ */
+ @Test
+ public void fails_close() throws Exception {
+ var out = multipleFails(new BufferedOutputStream(OutputStream.nullOutputStream()) {
+ @Override
+ public void close() throws IOException {
+ throw new IOException("Exception for test");
+ }
+ });
+ try {
+ out.close();
+ fail();
+ } catch (IOException e) {
+ assertException(e);
+ }
+ }
+
+ private TeeOutputStream multipleFails(OutputStream failingOutputStream) {
+ return new TeeOutputStream(
+ failingOutputStream,
+ new OutputStream[] {failingOutputStream, OutputStream.nullOutputStream()});
+ }
+
+ private void assertException(IOException e) {
+ assertThat(e.getMessage(), is("Exception for test"));
+ assertThat(e.getSuppressed().length, is(1));
+ assertThat(e.getSuppressed()[0].getMessage(), is("Exception for test"));
+ }
+}
diff --git a/src/test/resources/org/jenkinsci/plugins/workflow/log/configuration/jcasc_duplicate.yaml b/src/test/resources/org/jenkinsci/plugins/workflow/log/configuration/jcasc_duplicate.yaml
new file mode 100644
index 00000000..cd445662
--- /dev/null
+++ b/src/test/resources/org/jenkinsci/plugins/workflow/log/configuration/jcasc_duplicate.yaml
@@ -0,0 +1,8 @@
+unclassified:
+ pipelineLogging:
+ factory:
+ tee:
+ primary:
+ logMock1: {}
+ secondary:
+ logMock1
diff --git a/src/test/resources/org/jenkinsci/plugins/workflow/log/configuration/jcasc_empty.yaml b/src/test/resources/org/jenkinsci/plugins/workflow/log/configuration/jcasc_empty.yaml
new file mode 100644
index 00000000..bdb50dcd
--- /dev/null
+++ b/src/test/resources/org/jenkinsci/plugins/workflow/log/configuration/jcasc_empty.yaml
@@ -0,0 +1,4 @@
+unclassified:
+ pipelineLogging:
+ factory:
+ tee
diff --git a/src/test/resources/org/jenkinsci/plugins/workflow/log/configuration/jcasc_primary-only.yaml b/src/test/resources/org/jenkinsci/plugins/workflow/log/configuration/jcasc_primary-only.yaml
new file mode 100644
index 00000000..e12aeafc
--- /dev/null
+++ b/src/test/resources/org/jenkinsci/plugins/workflow/log/configuration/jcasc_primary-only.yaml
@@ -0,0 +1,5 @@
+unclassified:
+ pipelineLogging:
+ factory:
+ tee:
+ primary: logMock1
diff --git a/src/test/resources/org/jenkinsci/plugins/workflow/log/configuration/jcasc_secondary-only.yaml b/src/test/resources/org/jenkinsci/plugins/workflow/log/configuration/jcasc_secondary-only.yaml
new file mode 100644
index 00000000..2ec8bc55
--- /dev/null
+++ b/src/test/resources/org/jenkinsci/plugins/workflow/log/configuration/jcasc_secondary-only.yaml
@@ -0,0 +1,5 @@
+unclassified:
+ pipelineLogging:
+ factory:
+ tee:
+ secondary: logMock2
diff --git a/src/test/resources/org/jenkinsci/plugins/workflow/log/configuration/jcasc_smokes.yaml b/src/test/resources/org/jenkinsci/plugins/workflow/log/configuration/jcasc_smokes.yaml
new file mode 100644
index 00000000..6a417351
--- /dev/null
+++ b/src/test/resources/org/jenkinsci/plugins/workflow/log/configuration/jcasc_smokes.yaml
@@ -0,0 +1,8 @@
+unclassified:
+ pipelineLogging:
+ factory:
+ tee:
+ primary:
+ logMock1: {}
+ secondary:
+ logMock2