diff --git a/CHANGELOG.md b/CHANGELOG.md index d91d6ca8335..4051ebc4271 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ * Fix #5368: added support for additional ListOptions fields * Fix #5377: added a createOr and unlock function to provide a straight-forward replacement for createOrReplace. * Fix #5388: [crd-generator] Generate deterministic CRDs +* Fix #5257: Add ErrorStreamMessage and StatusStreamMessage to ease mocking of pods/exec requests #### Dependency Upgrade * Fix #5373: Gradle base API based on v8.2.1 diff --git a/junit/kubernetes-server-mock/src/main/java/io/fabric8/kubernetes/client/server/mock/ErrorStreamMessage.java b/junit/kubernetes-server-mock/src/main/java/io/fabric8/kubernetes/client/server/mock/ErrorStreamMessage.java new file mode 100644 index 00000000000..4e02d8a1053 --- /dev/null +++ b/junit/kubernetes-server-mock/src/main/java/io/fabric8/kubernetes/client/server/mock/ErrorStreamMessage.java @@ -0,0 +1,30 @@ +/** + * Copyright (C) 2015 Red Hat, Inc. + * + * 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 io.fabric8.kubernetes.client.server.mock; + +import io.fabric8.mockwebserver.internal.WebSocketMessage; + +import static io.fabric8.kubernetes.client.server.mock.OutputStreamMessage.getBodyBytes; + +public class ErrorStreamMessage extends WebSocketMessage { + + static final byte ERR_STREAM_ID = 2; + + public ErrorStreamMessage(String body) { + super(0L, getBodyBytes(ERR_STREAM_ID, body), true, true); + } +} diff --git a/junit/kubernetes-server-mock/src/main/java/io/fabric8/kubernetes/client/server/mock/OutputStreamMessage.java b/junit/kubernetes-server-mock/src/main/java/io/fabric8/kubernetes/client/server/mock/OutputStreamMessage.java index d8b6dca6a45..97c0fe685fb 100644 --- a/junit/kubernetes-server-mock/src/main/java/io/fabric8/kubernetes/client/server/mock/OutputStreamMessage.java +++ b/junit/kubernetes-server-mock/src/main/java/io/fabric8/kubernetes/client/server/mock/OutputStreamMessage.java @@ -22,18 +22,17 @@ public class OutputStreamMessage extends WebSocketMessage { - private static final byte OUT_STREAM_ID = 1; + static final byte OUT_STREAM_ID = 1; public OutputStreamMessage(String body) { super(0L, getBodyBytes(OUT_STREAM_ID, body), true, true); } - private static byte[] getBodyBytes(byte prefix, String body) { + static byte[] getBodyBytes(byte prefix, String body) { byte[] original = body.getBytes(StandardCharsets.UTF_8); byte[] prefixed = new byte[original.length + 1]; prefixed[0] = prefix; System.arraycopy(original, 0, prefixed, 1, original.length); return prefixed; } - } diff --git a/junit/kubernetes-server-mock/src/main/java/io/fabric8/kubernetes/client/server/mock/StatusStreamMessage.java b/junit/kubernetes-server-mock/src/main/java/io/fabric8/kubernetes/client/server/mock/StatusStreamMessage.java new file mode 100644 index 00000000000..313eef5665b --- /dev/null +++ b/junit/kubernetes-server-mock/src/main/java/io/fabric8/kubernetes/client/server/mock/StatusStreamMessage.java @@ -0,0 +1,47 @@ +/** + * Copyright (C) 2015 Red Hat, Inc. + * + * 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 io.fabric8.kubernetes.client.server.mock; + +import io.fabric8.kubernetes.api.model.Status; +import io.fabric8.kubernetes.api.model.StatusBuilder; +import io.fabric8.kubernetes.client.utils.Serialization; +import io.fabric8.mockwebserver.internal.WebSocketMessage; + +import static io.fabric8.kubernetes.client.server.mock.OutputStreamMessage.getBodyBytes; + +public class StatusStreamMessage extends WebSocketMessage { + + static final byte ERROR_CHANNEL_STREAM_ID = 3; + + public StatusStreamMessage(final int exitCode) { + super(0L, getBodyBytes(ERROR_CHANNEL_STREAM_ID, getStatusBody(exitCode)), true, true); + } + + private static String getStatusBody(int exitCode) { + final Status status = new StatusBuilder() // + .withStatus(exitCode == 0 ? "Success" : "Failure") + .withReason(exitCode == 0 ? "ExitCode" : "NonZeroExitCode") + .withNewDetails() + .addNewCause() + .withReason("ExitCode") + .withMessage(String.valueOf(exitCode)) + .endCause() + .endDetails() + .build(); + return Serialization.asJson(status); + } +} diff --git a/junit/kubernetes-server-mock/src/test/java/io/fabric8/kubernetes/client/server/mock/ErrorStreamMessageTest.java b/junit/kubernetes-server-mock/src/test/java/io/fabric8/kubernetes/client/server/mock/ErrorStreamMessageTest.java new file mode 100644 index 00000000000..4d7bef4fcea --- /dev/null +++ b/junit/kubernetes-server-mock/src/test/java/io/fabric8/kubernetes/client/server/mock/ErrorStreamMessageTest.java @@ -0,0 +1,33 @@ +/** + * Copyright (C) 2015 Red Hat, Inc. + * + * 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 io.fabric8.kubernetes.client.server.mock; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ErrorStreamMessageTest { + + @Test + void testMessageEncoding() { + final ErrorStreamMessage message = new ErrorStreamMessage("foobar"); + assertThat(message.isBinary()).isTrue(); + assertThat(message.isToBeRemoved()).isTrue(); + assertThat(message.getBytes()).startsWith(ErrorStreamMessage.ERR_STREAM_ID); + assertThat(message.getBody().substring(1)).isEqualTo("foobar"); + } +} diff --git a/junit/kubernetes-server-mock/src/test/java/io/fabric8/kubernetes/client/server/mock/OutputStreamMessageTest.java b/junit/kubernetes-server-mock/src/test/java/io/fabric8/kubernetes/client/server/mock/OutputStreamMessageTest.java new file mode 100644 index 00000000000..c77a1e8f58e --- /dev/null +++ b/junit/kubernetes-server-mock/src/test/java/io/fabric8/kubernetes/client/server/mock/OutputStreamMessageTest.java @@ -0,0 +1,33 @@ +/** + * Copyright (C) 2015 Red Hat, Inc. + * + * 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 io.fabric8.kubernetes.client.server.mock; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class OutputStreamMessageTest { + + @Test + void testMessageEncoding() { + final OutputStreamMessage message = new OutputStreamMessage("foobar"); + assertThat(message.isBinary()).isTrue(); + assertThat(message.isToBeRemoved()).isTrue(); + assertThat(message.getBytes()).startsWith(OutputStreamMessage.OUT_STREAM_ID); + assertThat(message.getBody().substring(1)).isEqualTo("foobar"); + } +} diff --git a/junit/kubernetes-server-mock/src/test/java/io/fabric8/kubernetes/client/server/mock/StatusStreamMessageTest.java b/junit/kubernetes-server-mock/src/test/java/io/fabric8/kubernetes/client/server/mock/StatusStreamMessageTest.java new file mode 100644 index 00000000000..b2e28f389a4 --- /dev/null +++ b/junit/kubernetes-server-mock/src/test/java/io/fabric8/kubernetes/client/server/mock/StatusStreamMessageTest.java @@ -0,0 +1,67 @@ +/** + * Copyright (C) 2015 Red Hat, Inc. + * + * 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 io.fabric8.kubernetes.client.server.mock; + +import io.fabric8.kubernetes.api.model.Status; +import io.fabric8.kubernetes.api.model.StatusBuilder; +import io.fabric8.kubernetes.client.utils.Serialization; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class StatusStreamMessageTest { + + @Test + void testMessageEncoding_withExitCode0() { + final StatusStreamMessage message = new StatusStreamMessage(0); + assertThat(message.isBinary()).isTrue(); + assertThat(message.isToBeRemoved()).isTrue(); + assertThat(message.getBytes()).startsWith(StatusStreamMessage.ERROR_CHANNEL_STREAM_ID); + + final Status status = new StatusBuilder() // + .withStatus("Success") + .withReason("ExitCode") + .withNewDetails() + .addNewCause() + .withReason("ExitCode") + .withMessage(String.valueOf(0)) + .endCause() + .endDetails() + .build(); + assertThat(message.getBody().substring(1)).isEqualTo(Serialization.asJson(status)); + } + + @Test + void testMessageEncoding_withExitCode1() { + final StatusStreamMessage message = new StatusStreamMessage(1); + assertThat(message.isBinary()).isTrue(); + assertThat(message.isToBeRemoved()).isTrue(); + assertThat(message.getBytes()).startsWith(StatusStreamMessage.ERROR_CHANNEL_STREAM_ID); + + final Status status = new StatusBuilder() // + .withStatus("Failure") + .withReason("NonZeroExitCode") + .withNewDetails() + .addNewCause() + .withReason("ExitCode") + .withMessage(String.valueOf(1)) + .endCause() + .endDetails() + .build(); + assertThat(message.getBody().substring(1)).isEqualTo(Serialization.asJson(status)); + } +} diff --git a/kubernetes-tests/src/test/java/io/fabric8/kubernetes/client/mock/PodTest.java b/kubernetes-tests/src/test/java/io/fabric8/kubernetes/client/mock/PodTest.java index 48f4a8c6840..449b85f711b 100644 --- a/kubernetes-tests/src/test/java/io/fabric8/kubernetes/client/mock/PodTest.java +++ b/kubernetes-tests/src/test/java/io/fabric8/kubernetes/client/mock/PodTest.java @@ -32,21 +32,23 @@ import io.fabric8.kubernetes.client.Watch; import io.fabric8.kubernetes.client.Watcher; import io.fabric8.kubernetes.client.WatcherException; +import io.fabric8.kubernetes.client.dsl.CopyOrReadable; import io.fabric8.kubernetes.client.dsl.ExecListener; import io.fabric8.kubernetes.client.dsl.ExecWatch; import io.fabric8.kubernetes.client.dsl.NonNamespaceOperation; import io.fabric8.kubernetes.client.dsl.PodResource; import io.fabric8.kubernetes.client.dsl.internal.core.v1.PodOperationsImpl; import io.fabric8.kubernetes.client.server.mock.EnableKubernetesMockClient; +import io.fabric8.kubernetes.client.server.mock.ErrorStreamMessage; import io.fabric8.kubernetes.client.server.mock.KubernetesMockServer; import io.fabric8.kubernetes.client.server.mock.OutputStreamMessage; import io.fabric8.kubernetes.client.server.mock.StatusMessage; +import io.fabric8.kubernetes.client.server.mock.StatusStreamMessage; import io.fabric8.kubernetes.client.utils.InputStreamPumper; import io.fabric8.kubernetes.client.utils.Utils; import io.fabric8.mockwebserver.internal.WebSocketMessage; import okio.ByteString; import org.awaitility.Awaitility; -import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -65,6 +67,7 @@ import java.nio.channels.WritableByteChannel; import java.nio.charset.StandardCharsets; import java.nio.file.Path; +import java.time.Duration; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; @@ -204,7 +207,7 @@ void testEditMissing() { PodResource podOp = client.pods().withName("pod1"); // Then - Assertions.assertThrows(KubernetesClientException.class, () -> podOp.edit(p -> p)); + assertThrows(KubernetesClientException.class, () -> podOp.edit(p -> p)); } @Test @@ -231,11 +234,10 @@ void testDeleteMulti() { server.expect().withPath("/api/v1/namespaces/test/pods/pod1").andReturn(200, pod1).once(); server.expect().withPath("/api/v1/namespaces/ns1/pods/pod2").andReturn(200, pod2).once(); - Boolean deleted = client.pods().inAnyNamespace().delete(pod1, pod2); + boolean deleted = client.pods().inAnyNamespace().delete(pod1, pod2); assertTrue(deleted); - deleted = client.pods().inAnyNamespace().delete(pod3).size() == 1; - assertFalse(deleted); + assertEquals(0, client.pods().inAnyNamespace().delete(pod3).size()); } @Test @@ -245,7 +247,7 @@ void testDeleteWithNamespaceMismatch() { // When + Then NonNamespaceOperation podOp = client.pods().inNamespace("test1"); - assertFalse(podOp.delete(pod1).size() == 1); + assertEquals(0, podOp.delete(pod1).size()); } @Test @@ -253,13 +255,12 @@ void testDeleteWithPropagationPolicy() { Pod pod1 = new PodBuilder().withNewMetadata().withName("pod1").withNamespace("test").and().build(); server.expect().withPath("/api/v1/namespaces/test/pods/pod1").andReturn(200, pod1).once(); - Boolean deleted = client.pods() + assertEquals(1, client.pods() .inNamespace("test") .withName("pod1") .withPropagationPolicy(DeletionPropagation.FOREGROUND) .delete() - .size() == 1; - assertTrue(deleted); + .size()); } @Test @@ -285,12 +286,12 @@ void testEvict() { .andReturn(500, new PodBuilder().build()) .once(); - Boolean deleted = client.pods().withName("pod1").evict(); + boolean deleted = client.pods().withName("pod1").evict(); assertTrue(deleted); // not found PodResource podResource = client.pods().withName("pod2"); - assertThrows(KubernetesClientException.class, () -> podResource.evict()); + assertThrows(KubernetesClientException.class, podResource::evict); deleted = client.pods().inNamespace("ns1").withName("pod2").evict(); assertTrue(deleted); @@ -334,7 +335,7 @@ void testCreateWithNameMismatch() { Pod pod1 = new PodBuilder().withNewMetadata().withName("pod1").withNamespace("test").and().build(); PodResource podOp = client.pods().inNamespace("test1").withName("mypod1"); - Assertions.assertThrows(KubernetesClientException.class, () -> podOp.create(pod1)); + assertThrows(KubernetesClientException.class, () -> podOp.create(pod1)); } @Test @@ -412,12 +413,82 @@ void testExec() throws InterruptedException { .usingListener(createCountDownLatchListener(execLatch)) .exec("ls"); - execLatch.await(10, TimeUnit.MINUTES); + assertTrue(execLatch.await(10, TimeUnit.MINUTES)); assertNotNull(watch); assertEquals(expectedOutput, baos.toString()); watch.close(); } + @Test + void testExecWithErrorOutput() throws InterruptedException { + String expectedError = "ls: cannot open directory '/': Permission denied"; + server.expect() + .withPath("/api/v1/namespaces/test/pods/pod1/exec?command=ls&container=default&stderr=true") + .andUpgradeToWebSocket() + .open(new ErrorStreamMessage(expectedError)) + .done() + .always(); + server.expect() + .withPath("/api/v1/namespaces/test/pods/pod1") + .andReturn(200, + new PodBuilder().withNewMetadata() + .endMetadata() + .withNewSpec() + .addNewContainer() + .withName("default") + .endContainer() + .endSpec() + .build()) + .once(); + + final CountDownLatch execLatch = new CountDownLatch(1); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ExecWatch watch = client.pods() + .withName("pod1") + .writingError(baos) + .usingListener(createCountDownLatchListener(execLatch)) + .exec("ls"); + + assertTrue(execLatch.await(10, TimeUnit.MINUTES)); + assertNotNull(watch); + assertEquals(expectedError, baos.toString()); + watch.close(); + } + + @Test + void testExecWithExitCode() throws Exception { + server.expect() + .withPath("/api/v1/namespaces/test/pods/pod1/exec?command=ls&container=default&stdout=true") + .andUpgradeToWebSocket() + .open(new StatusStreamMessage(1)) + .done() + .always(); + server.expect() + .withPath("/api/v1/namespaces/test/pods/pod1") + .andReturn(200, + new PodBuilder().withNewMetadata() + .endMetadata() + .withNewSpec() + .addNewContainer() + .withName("default") + .endContainer() + .endSpec() + .build()) + .once(); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ExecWatch watch = client.pods() + .withName("pod1") + .writingOutput(baos) + .exec("ls"); + + final Integer exitCode = watch.exitCode().get(10, TimeUnit.MINUTES); + assertEquals(1, exitCode); + assertNotNull(watch); + assertEquals(0, baos.size()); + watch.close(); + } + @Test void testAttachWithWritingOutput() throws InterruptedException, IOException { // Given @@ -494,7 +565,7 @@ void testAttachWithWritingOutput() throws InterruptedException, IOException { } @Test - void testExecExplicitDefaultContainerMissing() throws InterruptedException, IOException { + void testExecExplicitDefaultContainerMissing() { server.expect() .withPath("/api/v1/namespaces/test/pods/pod1/exec?command=ls&container=first&stderr=true") .andUpgradeToWebSocket() @@ -591,7 +662,7 @@ void testAttachWithRedirectOutput() throws InterruptedException, IOException { .until(() -> stdout.toString().equals(expectedOutput) && stderr.toString().equals(expectedError)); watch.close(); - latch.await(1, TimeUnit.MINUTES); + assertTrue(latch.await(1, TimeUnit.MINUTES)); } private ExecListener createCountDownLatchListener(CountDownLatch latch) { @@ -662,7 +733,7 @@ void testGetLogNotFound() { PodResource podOp = client.pods().withName("pod5"); // When + Then - Assertions.assertThrows(KubernetesClientException.class, () -> podOp.getLog(true)); + assertThrows(KubernetesClientException.class, () -> podOp.getLog(true)); } @Test @@ -672,7 +743,7 @@ void testLoad() { } @Test - void testWait() throws InterruptedException { + void testWait() { Pod notReady = new PodBuilder() .withNewMetadata() .withName("pod1") @@ -768,8 +839,7 @@ void testPortForward() throws IOException { } @Test - void testPortForwardWithChannel() throws InterruptedException, IOException { - + void testPortForwardWithChannel() throws IOException { server.expect() .withPath("/api/v1/namespaces/test/pods/pod1/portforward?ports=123") .andUpgradeToWebSocket() @@ -792,9 +862,7 @@ void testPortForwardWithChannel() throws InterruptedException, IOException { WritableByteChannel outChannel = Channels.newChannel(out); try (PortForward portForward = client.pods().withName("pod1").portForward(123, inChannel, outChannel)) { - while (portForward.isAlive()) { - Thread.sleep(100); - } + Awaitility.await().atMost(Duration.ofSeconds(60)).until(() -> !portForward.isAlive()); } String got = new String(out.toByteArray(), StandardCharsets.UTF_8); @@ -803,9 +871,9 @@ void testPortForwardWithChannel() throws InterruptedException, IOException { @Test void testOptionalUpload() { - Assertions.assertThrows(KubernetesClientException.class, () -> { - client.pods().inNamespace("ns1").withName("pod2").dir("/etc/hosts/dir").upload(tempDir.toAbsolutePath()); - }); + final CopyOrReadable dir = client.pods().inNamespace("ns1").withName("pod2").dir("/etc/hosts/dir"); + final Path absolutePath = tempDir.toAbsolutePath(); + assertThrows(KubernetesClientException.class, () -> dir.upload(absolutePath)); } @Test @@ -826,44 +894,33 @@ void testOptionalCopy() { .build()) .once(); - Assertions.assertThrows(KubernetesClientException.class, () -> { - client.pods().inNamespace("ns1").withName("pod2").file("/etc/hosts").copy(tempDir.toAbsolutePath()); - }); + final CopyOrReadable file = client.pods().inNamespace("ns1").withName("pod2").file("/etc/hosts"); + final Path absolutePath = tempDir.toAbsolutePath(); + assertThrows(KubernetesClientException.class, () -> file.copy(absolutePath)); } @Test void testOptionalCopyDir() { - Assertions.assertThrows(KubernetesClientException.class, () -> { - client.pods().inNamespace("ns1").withName("pod2").dir("/etc/hosts").copy(tempDir.toAbsolutePath()); - }); + final CopyOrReadable dir = client.pods().inNamespace("ns1").withName("pod2").dir("/etc/hosts"); + final Path absolutePath = tempDir.toAbsolutePath(); + assertThrows(KubernetesClientException.class, () -> dir.copy(absolutePath)); } @Test - void testPipesNotAllowed() { - PipedInputStream in = new PipedInputStream(); - PipedOutputStream out = new PipedOutputStream(); + void testPipesNotAllowed() throws IOException { + try (PipedInputStream in = new PipedInputStream(); PipedOutputStream out = new PipedOutputStream()) { + PodResource podOp = client.pods().inNamespace("ns1").withName("pod2"); - PodResource podOp = client.pods().inNamespace("ns1").withName("pod2"); + assertThrows(KubernetesClientException.class, () -> podOp.watchLog(out)); - Assertions.assertThrows(KubernetesClientException.class, () -> { - podOp.watchLog(out); - }); + assertThrows(KubernetesClientException.class, () -> podOp.writingError(out)); - Assertions.assertThrows(KubernetesClientException.class, () -> { - podOp.writingError(out); - }); + assertThrows(KubernetesClientException.class, () -> podOp.writingErrorChannel(out)); - Assertions.assertThrows(KubernetesClientException.class, () -> { - podOp.writingErrorChannel(out); - }); + assertThrows(KubernetesClientException.class, () -> podOp.writingOutput(out)); - Assertions.assertThrows(KubernetesClientException.class, () -> { - podOp.writingOutput(out); - }); - - Assertions.assertThrows(KubernetesClientException.class, () -> { - podOp.readingInput(in); - }); + assertThrows(KubernetesClientException.class, () -> podOp.readingInput(in)); + } } @Test @@ -989,7 +1046,7 @@ void testExecEphemeralContainer() throws InterruptedException { .usingListener(createCountDownLatchListener(execLatch)) .exec("ls"); - execLatch.await(10, TimeUnit.MINUTES); + assertTrue(execLatch.await(10, TimeUnit.MINUTES)); assertNotNull(watch); assertEquals(expectedOutput, baos.toString()); watch.close();