diff --git a/src/main/java/com/google/devtools/build/lib/actions/ActionProgressEvent.java b/src/main/java/com/google/devtools/build/lib/actions/ActionProgressEvent.java new file mode 100644 index 00000000000000..72e450284ecc73 --- /dev/null +++ b/src/main/java/com/google/devtools/build/lib/actions/ActionProgressEvent.java @@ -0,0 +1,39 @@ +// Copyright 2021 The Bazel Authors. All rights reserved. +// +// 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 com.google.devtools.build.lib.actions; + +import com.google.auto.value.AutoValue; +import com.google.devtools.build.lib.events.ExtendedEventHandler.ProgressLike; + +/** Notifications for the progress of an in-flight action. */ +@AutoValue +public abstract class ActionProgressEvent implements ProgressLike { + + public static ActionProgressEvent create( + ActionExecutionMetadata action, String progressId, String progress, boolean finished) { + return new AutoValue_ActionProgressEvent(action, progressId, progress, finished); + } + + /** Gets the metadata associated with the action being scheduled. */ + public abstract ActionExecutionMetadata action(); + + /** The id that uniquely determines the progress among all progress events within an action. */ + public abstract String progressId(); + + /** Human readable description of the progress. */ + public abstract String progress(); + + /** Whether the download progress reported about is finished already. */ + public abstract boolean finished(); +} diff --git a/src/main/java/com/google/devtools/build/lib/exec/AbstractSpawnStrategy.java b/src/main/java/com/google/devtools/build/lib/exec/AbstractSpawnStrategy.java index 03ec3f14f3d3b3..e4fed39e3d31c4 100644 --- a/src/main/java/com/google/devtools/build/lib/exec/AbstractSpawnStrategy.java +++ b/src/main/java/com/google/devtools/build/lib/exec/AbstractSpawnStrategy.java @@ -318,7 +318,6 @@ public void report(ProgressStatus progress) { return; } - // TODO(ulfjack): We should report more details to the UI. ExtendedEventHandler eventHandler = actionExecutionContext.getEventHandler(); progress.postTo(eventHandler, action); } diff --git a/src/main/java/com/google/devtools/build/lib/exec/BUILD b/src/main/java/com/google/devtools/build/lib/exec/BUILD index bc21d403468eda..676037aff93c0a 100644 --- a/src/main/java/com/google/devtools/build/lib/exec/BUILD +++ b/src/main/java/com/google/devtools/build/lib/exec/BUILD @@ -267,6 +267,7 @@ java_library( srcs = [ "SpawnCheckingCacheEvent.java", "SpawnExecutingEvent.java", + "SpawnProgressEvent.java", "SpawnRunner.java", "SpawnSchedulingEvent.java", ], diff --git a/src/main/java/com/google/devtools/build/lib/exec/SpawnProgressEvent.java b/src/main/java/com/google/devtools/build/lib/exec/SpawnProgressEvent.java new file mode 100644 index 00000000000000..9ddde03a1e1497 --- /dev/null +++ b/src/main/java/com/google/devtools/build/lib/exec/SpawnProgressEvent.java @@ -0,0 +1,43 @@ +// Copyright 2021 The Bazel Authors. All rights reserved. +// +// 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 com.google.devtools.build.lib.exec; + +import com.google.auto.value.AutoValue; +import com.google.devtools.build.lib.actions.ActionExecutionMetadata; +import com.google.devtools.build.lib.actions.ActionProgressEvent; +import com.google.devtools.build.lib.events.ExtendedEventHandler; +import com.google.devtools.build.lib.exec.SpawnRunner.ProgressStatus; + +/** The {@link SpawnRunner} is making some progress. */ +@AutoValue +public abstract class SpawnProgressEvent implements ProgressStatus { + + public static SpawnProgressEvent create(String resourceId, String progress, boolean finished) { + return new AutoValue_SpawnProgressEvent(resourceId, progress, finished); + } + + /** The id that uniquely determines the progress among all progress events for this spawn. */ + abstract String progressId(); + + /** Human readable description of the progress. */ + abstract String progress(); + + /** Whether the progress reported about is finished already. */ + abstract boolean finished(); + + @Override + public void postTo(ExtendedEventHandler eventHandler, ActionExecutionMetadata action) { + eventHandler.post(ActionProgressEvent.create(action, progressId(), progress(), finished())); + } +} diff --git a/src/main/java/com/google/devtools/build/lib/remote/RemoteCache.java b/src/main/java/com/google/devtools/build/lib/remote/RemoteCache.java index 2ac802907e6fbb..d978ad6250dea9 100644 --- a/src/main/java/com/google/devtools/build/lib/remote/RemoteCache.java +++ b/src/main/java/com/google/devtools/build/lib/remote/RemoteCache.java @@ -15,6 +15,8 @@ import static com.google.common.util.concurrent.Futures.immediateFuture; import static com.google.common.util.concurrent.MoreExecutors.directExecutor; +import static com.google.devtools.build.lib.remote.common.ProgressStatusListener.NO_ACTION; +import static com.google.devtools.build.lib.remote.util.Utils.bytesCountToDisplayString; import static com.google.devtools.build.lib.remote.util.Utils.getFromFuture; import build.bazel.remote.execution.v2.Action; @@ -50,6 +52,7 @@ import com.google.devtools.build.lib.actions.UserExecException; import com.google.devtools.build.lib.actions.cache.MetadataInjector; import com.google.devtools.build.lib.concurrent.ThreadSafety; +import com.google.devtools.build.lib.exec.SpawnProgressEvent; import com.google.devtools.build.lib.exec.SpawnRunner.SpawnExecutionContext; import com.google.devtools.build.lib.profiler.Profiler; import com.google.devtools.build.lib.profiler.SilentCloseable; @@ -58,6 +61,7 @@ import com.google.devtools.build.lib.remote.RemoteCache.ActionResultMetadata.SymlinkMetadata; import com.google.devtools.build.lib.remote.common.LazyFileOutputStream; import com.google.devtools.build.lib.remote.common.OutputDigestMismatchException; +import com.google.devtools.build.lib.remote.common.ProgressStatusListener; import com.google.devtools.build.lib.remote.common.RemoteActionExecutionContext; import com.google.devtools.build.lib.remote.common.RemoteActionFileArtifactValue; import com.google.devtools.build.lib.remote.common.RemoteCacheClient; @@ -91,6 +95,9 @@ import java.util.List; import java.util.Map; import java.util.Map.Entry; +import java.util.concurrent.atomic.AtomicLong; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; import javax.annotation.Nullable; @@ -314,6 +321,50 @@ private static Path toTmpDownloadPath(Path actualPath) { return actualPath.getParentDirectory().getRelative(actualPath.getBaseName() + ".tmp"); } + static class DownloadProgressReporter { + private static final Pattern PATTERN = Pattern.compile("^bazel-out/[^/]+/[^/]+/"); + private final ProgressStatusListener listener; + private final String id; + private final String file; + private final String totalSize; + private final AtomicLong downloadedBytes = new AtomicLong(0); + + DownloadProgressReporter(ProgressStatusListener listener, String file, long totalSize) { + this.listener = listener; + this.id = file; + this.totalSize = bytesCountToDisplayString(totalSize); + + Matcher matcher = PATTERN.matcher(file); + this.file = matcher.replaceFirst(""); + } + + void started() { + reportProgress(false, false); + } + + void downloadedBytes(int count) { + downloadedBytes.addAndGet(count); + reportProgress(true, false); + } + + void finished() { + reportProgress(true, true); + } + + private void reportProgress(boolean includeBytes, boolean finished) { + String progress; + if (includeBytes) { + progress = + String.format( + "Downloading %s, %s / %s", + file, bytesCountToDisplayString(downloadedBytes.get()), totalSize); + } else { + progress = String.format("Downloading %s", file); + } + listener.onProgressStatus(SpawnProgressEvent.create(id, progress, finished)); + } + } + /** * Download the output files and directory trees of a remotely executed action to the local * machine, as well stdin / stdout to the given files. @@ -330,7 +381,8 @@ public void download( RemotePathResolver remotePathResolver, ActionResult result, FileOutErr origOutErr, - OutputFilesLocker outputFilesLocker) + OutputFilesLocker outputFilesLocker, + ProgressStatusListener progressStatusListener) throws ExecException, IOException, InterruptedException { ActionResultMetadata metadata = parseActionResultMetadata(context, remotePathResolver, result); @@ -347,7 +399,11 @@ public void download( context, remotePathResolver.localPathToOutputPath(file.path()), toTmpDownloadPath(file.path()), - file.digest()); + file.digest(), + new DownloadProgressReporter( + progressStatusListener, + remotePathResolver.localPathToOutputPath(file.path()), + file.digest().getSizeBytes())); return Futures.transform(download, (d) -> file, directExecutor()); } catch (IOException e) { return Futures.immediateFailedFuture(e); @@ -499,10 +555,14 @@ private void createSymlinks(Iterable symlinks) throws IOExcepti } public ListenableFuture downloadFile( - RemoteActionExecutionContext context, String outputPath, Path localPath, Digest digest) + RemoteActionExecutionContext context, + String outputPath, + Path localPath, + Digest digest, + DownloadProgressReporter reporter) throws IOException { SettableFuture outerF = SettableFuture.create(); - ListenableFuture f = downloadFile(context, localPath, digest); + ListenableFuture f = downloadFile(context, localPath, digest, reporter); Futures.addCallback( f, new FutureCallback() { @@ -529,6 +589,16 @@ public void onFailure(Throwable throwable) { /** Downloads a file (that is not a directory). The content is fetched from the digest. */ public ListenableFuture downloadFile( RemoteActionExecutionContext context, Path path, Digest digest) throws IOException { + return downloadFile(context, path, digest, new DownloadProgressReporter(NO_ACTION, "", 0)); + } + + /** Downloads a file (that is not a directory). The content is fetched from the digest. */ + public ListenableFuture downloadFile( + RemoteActionExecutionContext context, + Path path, + Digest digest, + DownloadProgressReporter reporter) + throws IOException { Preconditions.checkNotNull(path.getParentDirectory()).createDirectoryAndParents(); if (digest.getSizeBytes() == 0) { // Handle empty file locally. @@ -549,7 +619,9 @@ public ListenableFuture downloadFile( return COMPLETED_SUCCESS; } - OutputStream out = new LazyFileOutputStream(path); + reporter.started(); + OutputStream out = new ReportingOutputStream(new LazyFileOutputStream(path), reporter); + SettableFuture outerF = SettableFuture.create(); ListenableFuture f = cacheProtocol.downloadBlob(context, digest, out); Futures.addCallback( @@ -560,6 +632,7 @@ public void onSuccess(Void result) { try { out.close(); outerF.set(null); + reporter.finished(); } catch (IOException e) { outerF.setException(e); } catch (RuntimeException e) { @@ -572,6 +645,7 @@ public void onSuccess(Void result) { public void onFailure(Throwable t) { try { out.close(); + reporter.finished(); } catch (IOException e) { if (t != e) { t.addSuppressed(e); @@ -1100,6 +1174,49 @@ private static FailureDetail createFailureDetail(String message, Code detailedCo .build(); } + /** + * An {@link OutputStream} that reports all the write operations with {@link + * DownloadProgressReporter}. + */ + private static class ReportingOutputStream extends OutputStream { + + private final OutputStream out; + private final DownloadProgressReporter reporter; + + ReportingOutputStream(OutputStream out, DownloadProgressReporter reporter) { + this.out = out; + this.reporter = reporter; + } + + @Override + public void write(byte[] b) throws IOException { + out.write(b); + reporter.downloadedBytes(b.length); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + out.write(b, off, len); + reporter.downloadedBytes(len); + } + + @Override + public void write(int b) throws IOException { + out.write(b); + reporter.downloadedBytes(1); + } + + @Override + public void flush() throws IOException { + out.flush(); + } + + @Override + public void close() throws IOException { + out.close(); + } + } + /** In-memory representation of action result metadata. */ static class ActionResultMetadata { diff --git a/src/main/java/com/google/devtools/build/lib/remote/RemoteExecutionService.java b/src/main/java/com/google/devtools/build/lib/remote/RemoteExecutionService.java index e24cac1ace14ab..00c98a7e9f0533 100644 --- a/src/main/java/com/google/devtools/build/lib/remote/RemoteExecutionService.java +++ b/src/main/java/com/google/devtools/build/lib/remote/RemoteExecutionService.java @@ -385,7 +385,8 @@ public InMemoryOutput downloadOutputs(RemoteAction action, RemoteActionResult re remotePathResolver, result.actionResult, action.spawnExecutionContext.getFileOutErr(), - action.spawnExecutionContext::lockOutputFiles); + action.spawnExecutionContext::lockOutputFiles, + action.spawnExecutionContext::report); } else { PathFragment inMemoryOutputPath = getInMemoryOutputPath(action.spawn); inMemoryOutput = diff --git a/src/main/java/com/google/devtools/build/lib/remote/common/ProgressStatusListener.java b/src/main/java/com/google/devtools/build/lib/remote/common/ProgressStatusListener.java new file mode 100644 index 00000000000000..df5a8beae54758 --- /dev/null +++ b/src/main/java/com/google/devtools/build/lib/remote/common/ProgressStatusListener.java @@ -0,0 +1,29 @@ +// Copyright 2021 The Bazel Authors. All rights reserved. +// +// 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 com.google.devtools.build.lib.remote.common; + +import com.google.devtools.build.lib.exec.SpawnRunner.ProgressStatus; + +/** An interface that is used to receive {@link ProgressStatus} updates during spawn execution. */ +@FunctionalInterface +public interface ProgressStatusListener { + + void onProgressStatus(ProgressStatus progress); + + /** A {@link ProgressStatusListener} that does nothing. */ + ProgressStatusListener NO_ACTION = + progress -> { + // Intentionally left empty + }; +} diff --git a/src/main/java/com/google/devtools/build/lib/remote/util/Utils.java b/src/main/java/com/google/devtools/build/lib/remote/util/Utils.java index 1a2d8093196f55..acacffd1759e5b 100644 --- a/src/main/java/com/google/devtools/build/lib/remote/util/Utils.java +++ b/src/main/java/com/google/devtools/build/lib/remote/util/Utils.java @@ -22,6 +22,7 @@ import com.google.common.base.Ascii; import com.google.common.base.Preconditions; import com.google.common.base.Throwables; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import com.google.common.util.concurrent.AsyncCallable; import com.google.common.util.concurrent.FluentFuture; @@ -61,6 +62,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; +import java.text.DecimalFormat; import java.util.Arrays; import java.util.Collection; import java.util.Locale; @@ -509,4 +511,30 @@ public static V refreshIfUnauthenticated( throw new AssertionError(e); } } + + private static final ImmutableList UNITS = ImmutableList.of("KiB", "MiB", "GiB", "TiB"); + + /** + * Converts the number of bytes to a human readable string, e.g. 1024 -> 1 KiB. + * + *

Negative numbers are not allowed. + */ + public static String bytesCountToDisplayString(long bytes) { + Preconditions.checkArgument(bytes >= 0); + + if (bytes < 1024) { + return bytes + " B"; + } + + int unitIndex = 0; + long value = bytes; + while ((unitIndex + 1) < UNITS.size() && value >= (1 << 20)) { + value >>= 10; + unitIndex++; + } + + // Format as single digit decimal number, but skipping the trailing .0. + DecimalFormat fmt = new DecimalFormat("0.#"); + return String.format("%s %s", fmt.format(value / 1024.0), UNITS.get(unitIndex)); + } } diff --git a/src/main/java/com/google/devtools/build/lib/runtime/UiEventHandler.java b/src/main/java/com/google/devtools/build/lib/runtime/UiEventHandler.java index f903cdff0be2d9..ec2d2b4a91419b 100644 --- a/src/main/java/com/google/devtools/build/lib/runtime/UiEventHandler.java +++ b/src/main/java/com/google/devtools/build/lib/runtime/UiEventHandler.java @@ -20,6 +20,7 @@ import com.google.common.primitives.Bytes; import com.google.common.util.concurrent.Uninterruptibles; import com.google.devtools.build.lib.actions.ActionCompletionEvent; +import com.google.devtools.build.lib.actions.ActionProgressEvent; import com.google.devtools.build.lib.actions.ActionScanningCompletedEvent; import com.google.devtools.build.lib.actions.ActionStartedEvent; import com.google.devtools.build.lib.actions.CachingActionEvent; @@ -700,6 +701,13 @@ public void runningAction(RunningActionEvent event) { refresh(); } + @Subscribe + @AllowConcurrentEvents + public void actionProgress(ActionProgressEvent event) { + stateTracker.actionProgress(event); + refresh(); + } + @Subscribe @AllowConcurrentEvents public void actionCompletion(ActionScanningCompletedEvent event) { diff --git a/src/main/java/com/google/devtools/build/lib/runtime/UiStateTracker.java b/src/main/java/com/google/devtools/build/lib/runtime/UiStateTracker.java index 4fa69856d9bac8..c709eb36316840 100644 --- a/src/main/java/com/google/devtools/build/lib/runtime/UiStateTracker.java +++ b/src/main/java/com/google/devtools/build/lib/runtime/UiStateTracker.java @@ -25,6 +25,7 @@ import com.google.devtools.build.lib.actions.Action; import com.google.devtools.build.lib.actions.ActionCompletionEvent; import com.google.devtools.build.lib.actions.ActionExecutionMetadata; +import com.google.devtools.build.lib.actions.ActionProgressEvent; import com.google.devtools.build.lib.actions.ActionScanningCompletedEvent; import com.google.devtools.build.lib.actions.ActionStartedEvent; import com.google.devtools.build.lib.actions.Artifact; @@ -58,6 +59,7 @@ import java.util.Comparator; import java.util.Deque; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.PriorityQueue; @@ -215,6 +217,19 @@ private static final class ActionState { */ int runningStrategiesBitmap = 0; + private static class ProgressState { + final String id; + final long nanoStartTime; + ActionProgressEvent latestEvent; + + private ProgressState(String id, long nanoStartTime) { + this.id = id; + this.nanoStartTime = nanoStartTime; + } + } + + private final LinkedHashMap runningProgresses = new LinkedHashMap<>(); + /** Starts tracking the state of an action. */ ActionState(ActionExecutionMetadata action, long nanoStartTime) { this.action = action; @@ -304,6 +319,20 @@ synchronized void setRunning(String strategy, long nanoChangeTime) { nanoStartTime = nanoChangeTime; } + /** Handles the progress event for the action. */ + synchronized void onProgressEvent(ActionProgressEvent event, long nanoChangeTime) { + String id = event.progressId(); + if (event.finished()) { + // a progress is finished, clean it up + runningProgresses.remove(id); + return; + } + + ProgressState state = + runningProgresses.computeIfAbsent(id, key -> new ProgressState(key, nanoChangeTime)); + state.latestEvent = event; + } + /** Generates a human-readable description of this action's state. */ synchronized String describe() { if (runningStrategiesBitmap != 0) { @@ -539,6 +568,13 @@ void runningAction(RunningActionEvent event) { getActionState(action, actionId, now).setRunning(event.getStrategy(), now); } + void actionProgress(ActionProgressEvent event) { + ActionExecutionMetadata action = event.action(); + Artifact actionId = event.action().getPrimaryOutput(); + long now = clock.nanoTime(); + getActionState(action, actionId, now).onProgressEvent(event, now); + } + void actionCompletion(ActionScanningCompletedEvent event) { Action action = event.getAction(); Artifact actionId = action.getPrimaryOutput(); @@ -668,6 +704,29 @@ private String describeTestGroup( return message.append(allReported ? "]" : postfix).toString(); } + private String describeActionProgress(ActionState action, int desiredWidth) { + if (action.runningProgresses.isEmpty()) { + return ""; + } + + ActionState.ProgressState state = + action.runningProgresses.entrySet().iterator().next().getValue(); + ActionProgressEvent event = state.latestEvent; + String message = event.progress(); + if (message.isEmpty()) { + message = state.id; + } + + message = "; " + message; + + if (desiredWidth <= 0 || message.length() <= desiredWidth) { + return message; + } + + message = message.substring(0, desiredWidth - ELLIPSIS.length()) + ELLIPSIS; + return message; + } + // Describe an action by a string of the desired length; if describing that action includes // describing other actions, add those to the to set of actions to skip in further samples of // actions. @@ -721,9 +780,24 @@ private String describeAction( message = action.prettyPrint(); } - if (desiredWidth <= 0) { - return prefix + message + postfix; + String progress = describeActionProgress(actionState, 0); + + if (desiredWidth <= 0 + || (prefix.length() + message.length() + progress.length() + postfix.length()) + <= desiredWidth) { + return prefix + message + progress + postfix; } + + // We have to shorten the progress to fit into the line. + int remainingWidthForProgress = + desiredWidth - prefix.length() - message.length() - postfix.length(); + int minWidthForProgress = 7; // "; " + at least two character + "..." + if (remainingWidthForProgress >= minWidthForProgress) { + progress = describeActionProgress(actionState, remainingWidthForProgress); + return prefix + message + progress + postfix; + } + + // We have to skip the progress to fit into the line. if (prefix.length() + message.length() + postfix.length() <= desiredWidth) { return prefix + message + postfix; } diff --git a/src/test/java/com/google/devtools/build/lib/remote/GrpcCacheClientTest.java b/src/test/java/com/google/devtools/build/lib/remote/GrpcCacheClientTest.java index 3c59a8418fb11f..ef159dbd189496 100644 --- a/src/test/java/com/google/devtools/build/lib/remote/GrpcCacheClientTest.java +++ b/src/test/java/com/google/devtools/build/lib/remote/GrpcCacheClientTest.java @@ -391,7 +391,12 @@ public void testDownloadAllResults() throws Exception { result.addOutputFilesBuilder().setPath("b/empty").setDigest(emptyDigest); result.addOutputFilesBuilder().setPath("a/bar").setDigest(barDigest).setIsExecutable(true); remoteCache.download( - context, remotePathResolver, result.build(), null, /* outputFilesLocker= */ () -> {}); + context, + remotePathResolver, + result.build(), + null, + /* outputFilesLocker= */ () -> {}, + progress -> {}); assertThat(DIGEST_UTIL.compute(execRoot.getRelative("a/foo"))).isEqualTo(fooDigest); assertThat(DIGEST_UTIL.compute(execRoot.getRelative("b/empty"))).isEqualTo(emptyDigest); assertThat(DIGEST_UTIL.compute(execRoot.getRelative("a/bar"))).isEqualTo(barDigest); @@ -416,7 +421,12 @@ public void testDownloadAllResultsForSiblingLayoutAndRelativeToInputRoot() throw result.addOutputFilesBuilder().setPath("main/b/empty").setDigest(emptyDigest); result.addOutputFilesBuilder().setPath("main/a/bar").setDigest(barDigest).setIsExecutable(true); remoteCache.download( - context, remotePathResolver, result.build(), null, /* outputFilesLocker= */ () -> {}); + context, + remotePathResolver, + result.build(), + null, + /* outputFilesLocker= */ () -> {}, + progress -> {}); assertThat(DIGEST_UTIL.compute(execRoot.getRelative("a/foo"))).isEqualTo(fooDigest); assertThat(DIGEST_UTIL.compute(execRoot.getRelative("b/empty"))).isEqualTo(emptyDigest); assertThat(DIGEST_UTIL.compute(execRoot.getRelative("a/bar"))).isEqualTo(barDigest); @@ -453,7 +463,12 @@ public void testDownloadDirectory() throws Exception { result.addOutputFilesBuilder().setPath("a/foo").setDigest(fooDigest); result.addOutputDirectoriesBuilder().setPath("a/bar").setTreeDigest(barTreeDigest); remoteCache.download( - context, remotePathResolver, result.build(), null, /* outputFilesLocker= */ () -> {}); + context, + remotePathResolver, + result.build(), + null, + /* outputFilesLocker= */ () -> {}, + progress -> {}); assertThat(DIGEST_UTIL.compute(execRoot.getRelative("a/foo"))).isEqualTo(fooDigest); assertThat(DIGEST_UTIL.compute(execRoot.getRelative("a/bar/qux"))).isEqualTo(quxDigest); @@ -475,7 +490,12 @@ public void testDownloadDirectoryEmpty() throws Exception { ActionResult.Builder result = ActionResult.newBuilder(); result.addOutputDirectoriesBuilder().setPath("a/bar").setTreeDigest(barTreeDigest); remoteCache.download( - context, remotePathResolver, result.build(), null, /* outputFilesLocker= */ () -> {}); + context, + remotePathResolver, + result.build(), + null, + /* outputFilesLocker= */ () -> {}, + progress -> {}); assertThat(execRoot.getRelative("a/bar").isDirectory()).isTrue(); } @@ -518,7 +538,12 @@ public void testDownloadDirectoryNested() throws Exception { result.addOutputFilesBuilder().setPath("a/foo").setDigest(fooDigest); result.addOutputDirectoriesBuilder().setPath("a/bar").setTreeDigest(barTreeDigest); remoteCache.download( - context, remotePathResolver, result.build(), null, /* outputFilesLocker= */ () -> {}); + context, + remotePathResolver, + result.build(), + null, + /* outputFilesLocker= */ () -> {}, + progress -> {}); assertThat(DIGEST_UTIL.compute(execRoot.getRelative("a/foo"))).isEqualTo(fooDigest); assertThat(DIGEST_UTIL.compute(execRoot.getRelative("a/bar/wobble/qux"))).isEqualTo(quxDigest); diff --git a/src/test/java/com/google/devtools/build/lib/remote/RemoteCacheTests.java b/src/test/java/com/google/devtools/build/lib/remote/RemoteCacheTests.java index 7836639534d8e0..6f84b4e68abc2e 100644 --- a/src/test/java/com/google/devtools/build/lib/remote/RemoteCacheTests.java +++ b/src/test/java/com/google/devtools/build/lib/remote/RemoteCacheTests.java @@ -55,6 +55,7 @@ import com.google.devtools.build.lib.clock.JavaClock; import com.google.devtools.build.lib.remote.RemoteCache.OutputFilesLocker; import com.google.devtools.build.lib.remote.RemoteCache.UploadManifest; +import com.google.devtools.build.lib.remote.common.ProgressStatusListener; import com.google.devtools.build.lib.remote.common.RemoteActionExecutionContext; import com.google.devtools.build.lib.remote.common.RemoteCacheClient.ActionKey; import com.google.devtools.build.lib.remote.common.RemotePathResolver; @@ -101,6 +102,7 @@ public class RemoteCacheTests { @Mock private OutputFilesLocker outputFilesLocker; + private final ProgressStatusListener progressStatusListener = progress -> {}; private RemoteActionExecutionContext context; private RemotePathResolver remotePathResolver; @@ -613,7 +615,13 @@ public void downloadRelativeFileSymlink() throws Exception { ActionResult.Builder result = ActionResult.newBuilder(); result.addOutputFileSymlinksBuilder().setPath("a/b/link").setTarget("../../foo"); // Doesn't check for dangling links, hence download succeeds. - cache.download(context, remotePathResolver, result.build(), null, outputFilesLocker); + cache.download( + context, + remotePathResolver, + result.build(), + null, + outputFilesLocker, + progressStatusListener); Path path = execRoot.getRelative("a/b/link"); assertThat(path.isSymbolicLink()).isTrue(); assertThat(path.readSymbolicLink()).isEqualTo(PathFragment.create("../../foo")); @@ -626,7 +634,13 @@ public void downloadRelativeDirectorySymlink() throws Exception { ActionResult.Builder result = ActionResult.newBuilder(); result.addOutputDirectorySymlinksBuilder().setPath("a/b/link").setTarget("foo"); // Doesn't check for dangling links, hence download succeeds. - cache.download(context, remotePathResolver, result.build(), null, outputFilesLocker); + cache.download( + context, + remotePathResolver, + result.build(), + null, + outputFilesLocker, + progressStatusListener); Path path = execRoot.getRelative("a/b/link"); assertThat(path.isSymbolicLink()).isTrue(); assertThat(path.readSymbolicLink()).isEqualTo(PathFragment.create("foo")); @@ -646,7 +660,13 @@ public void downloadRelativeSymlinkInDirectory() throws Exception { ActionResult.Builder result = ActionResult.newBuilder(); result.addOutputDirectoriesBuilder().setPath("dir").setTreeDigest(treeDigest); // Doesn't check for dangling links, hence download succeeds. - cache.download(context, remotePathResolver, result.build(), null, outputFilesLocker); + cache.download( + context, + remotePathResolver, + result.build(), + null, + outputFilesLocker, + progressStatusListener); Path path = execRoot.getRelative("dir/link"); assertThat(path.isSymbolicLink()).isTrue(); assertThat(path.readSymbolicLink()).isEqualTo(PathFragment.create("../foo")); @@ -663,7 +683,12 @@ public void downloadAbsoluteDirectorySymlinkError() throws Exception { IOException.class, () -> cache.download( - context, remotePathResolver, result.build(), null, outputFilesLocker)); + context, + remotePathResolver, + result.build(), + null, + outputFilesLocker, + progressStatusListener)); assertThat(expected).hasMessageThat().contains("/abs/link"); assertThat(expected).hasMessageThat().contains("absolute path"); verify(outputFilesLocker).lock(); @@ -679,7 +704,12 @@ public void downloadAbsoluteFileSymlinkError() throws Exception { IOException.class, () -> cache.download( - context, remotePathResolver, result.build(), null, outputFilesLocker)); + context, + remotePathResolver, + result.build(), + null, + outputFilesLocker, + progressStatusListener)); assertThat(expected).hasMessageThat().contains("/abs/link"); assertThat(expected).hasMessageThat().contains("absolute path"); verify(outputFilesLocker).lock(); @@ -702,7 +732,12 @@ public void downloadAbsoluteSymlinkInDirectoryError() throws Exception { IOException.class, () -> cache.download( - context, remotePathResolver, result.build(), null, outputFilesLocker)); + context, + remotePathResolver, + result.build(), + null, + outputFilesLocker, + progressStatusListener)); assertThat(expected.getSuppressed()).isEmpty(); assertThat(expected).hasMessageThat().contains("dir/link"); assertThat(expected).hasMessageThat().contains("/foo"); @@ -726,7 +761,14 @@ public void downloadFailureMaintainsDirectories() throws Exception { result.addOutputFiles(OutputFile.newBuilder().setPath("otherfile").setDigest(otherFileDigest)); assertThrows( BulkTransferException.class, - () -> cache.download(context, remotePathResolver, result.build(), null, outputFilesLocker)); + () -> + cache.download( + context, + remotePathResolver, + result.build(), + null, + outputFilesLocker, + progressStatusListener)); assertThat(cache.getNumFailedDownloads()).isEqualTo(1); assertThat(execRoot.getRelative("outputdir").exists()).isTrue(); assertThat(execRoot.getRelative("outputdir/outputfile").exists()).isFalse(); @@ -764,7 +806,8 @@ public void onErrorWaitForRemainingDownloadsToComplete() throws Exception { remotePathResolver, result, new FileOutErr(stdout, stderr), - outputFilesLocker)); + outputFilesLocker, + progressStatusListener)); assertThat(downloadException.getSuppressed()).hasLength(1); assertThat(cache.getNumSuccessfulDownloads()).isEqualTo(2); assertThat(cache.getNumFailedDownloads()).isEqualTo(1); @@ -800,7 +843,8 @@ public void downloadWithMultipleErrorsAddsThemAsSuppressed() throws Exception { remotePathResolver, result, new FileOutErr(stdout, stderr), - outputFilesLocker)); + outputFilesLocker, + progressStatusListener)); assertThat(e.getSuppressed()).hasLength(2); assertThat(e.getSuppressed()[0]).isInstanceOf(IOException.class); @@ -836,7 +880,8 @@ public void downloadWithDuplicateIOErrorsDoesNotSuppress() throws Exception { remotePathResolver, result, new FileOutErr(stdout, stderr), - outputFilesLocker)); + outputFilesLocker, + progressStatusListener)); for (Throwable t : downloadException.getSuppressed()) { assertThat(t).isInstanceOf(IOException.class); @@ -872,7 +917,8 @@ public void downloadWithDuplicateInterruptionsDoesNotSuppress() throws Exception remotePathResolver, result, new FileOutErr(stdout, stderr), - outputFilesLocker)); + outputFilesLocker, + progressStatusListener)); assertThat(e.getSuppressed()).isEmpty(); assertThat(Throwables.getRootCause(e)).hasMessageThat().isEqualTo("reused interruption"); @@ -901,7 +947,8 @@ public void testDownloadWithStdoutStderrOnSuccess() throws Exception { .setStderrDigest(digestStderr) .build(); - cache.download(context, remotePathResolver, result, spyOutErr, outputFilesLocker); + cache.download( + context, remotePathResolver, result, spyOutErr, outputFilesLocker, progressStatusListener); verify(spyOutErr, Mockito.times(2)).childOutErr(); verify(spyChildOutErr).clearOut(); @@ -944,7 +991,14 @@ public void testDownloadWithStdoutStderrOnFailure() throws Exception { .build(); assertThrows( BulkTransferException.class, - () -> cache.download(context, remotePathResolver, result, spyOutErr, outputFilesLocker)); + () -> + cache.download( + context, + remotePathResolver, + result, + spyOutErr, + outputFilesLocker, + progressStatusListener)); verify(spyOutErr, Mockito.times(2)).childOutErr(); verify(spyChildOutErr).clearOut(); verify(spyChildOutErr).clearErr(); @@ -981,7 +1035,13 @@ public void testDownloadClashes() throws Exception { // act - remoteCache.download(context, remotePathResolver, r, new FileOutErr(), outputFilesLocker); + remoteCache.download( + context, + remotePathResolver, + r, + new FileOutErr(), + outputFilesLocker, + progressStatusListener); // assert @@ -1364,7 +1424,12 @@ public void testDownloadDirectory() throws Exception { // act RemoteCache remoteCache = newRemoteCache(cas); remoteCache.download( - context, remotePathResolver, result.build(), null, /* outputFilesLocker= */ () -> {}); + context, + remotePathResolver, + result.build(), + null, + outputFilesLocker, + progressStatusListener); // assert assertThat(digestUtil.compute(execRoot.getRelative("a/foo"))).isEqualTo(fooDigest); @@ -1389,7 +1454,12 @@ public void testDownloadEmptyDirectory() throws Exception { // act RemoteCache remoteCache = newRemoteCache(map); remoteCache.download( - context, remotePathResolver, result.build(), null, /* outputFilesLocker= */ () -> {}); + context, + remotePathResolver, + result.build(), + null, + outputFilesLocker, + progressStatusListener); // assert assertThat(execRoot.getRelative("a/bar").isDirectory()).isTrue(); @@ -1434,7 +1504,12 @@ public void testDownloadNestedDirectory() throws Exception { // act RemoteCache remoteCache = newRemoteCache(map); remoteCache.download( - context, remotePathResolver, result.build(), null, /* outputFilesLocker= */ () -> {}); + context, + remotePathResolver, + result.build(), + null, + outputFilesLocker, + progressStatusListener); // assert assertThat(digestUtil.compute(execRoot.getRelative("a/foo"))).isEqualTo(fooDigest); @@ -1485,7 +1560,12 @@ public void testDownloadDirectoryWithSameHash() throws Exception { // act RemoteCache remoteCache = newRemoteCache(map); remoteCache.download( - context, remotePathResolver, result.build(), null, /* outputFilesLocker= */ () -> {}); + context, + remotePathResolver, + result.build(), + null, + outputFilesLocker, + progressStatusListener); // assert assertThat(digestUtil.compute(execRoot.getRelative("a/bar/foo/file"))).isEqualTo(fileDigest); diff --git a/src/test/java/com/google/devtools/build/lib/remote/RemoteSpawnCacheTest.java b/src/test/java/com/google/devtools/build/lib/remote/RemoteSpawnCacheTest.java index 4c273f6d1ee937..03e6c2b8a02a9b 100644 --- a/src/test/java/com/google/devtools/build/lib/remote/RemoteSpawnCacheTest.java +++ b/src/test/java/com/google/devtools/build/lib/remote/RemoteSpawnCacheTest.java @@ -273,13 +273,13 @@ public Void answer(InvocationOnMock invocation) { } }) .when(remoteCache) - .download(any(), any(), eq(actionResult), eq(outErr), any()); + .download(any(), any(), eq(actionResult), eq(outErr), any(), any()); CacheHandle entry = cache.lookup(simpleSpawn, simplePolicy); assertThat(entry.hasResult()).isTrue(); SpawnResult result = entry.getResult(); // All other methods on RemoteActionCache have side effects, so we verify all of them. - verify(remoteCache).download(any(), any(), eq(actionResult), eq(outErr), any()); + verify(remoteCache).download(any(), any(), eq(actionResult), eq(outErr), any(), any()); verify(remoteCache, never()) .upload( any(RemoteActionExecutionContext.class), @@ -643,7 +643,7 @@ public ActionResult answer(InvocationOnMock invocation) { }); doThrow(new CacheNotFoundException(digest)) .when(remoteCache) - .download(any(), any(), eq(actionResult), eq(outErr), any()); + .download(any(), any(), eq(actionResult), eq(outErr), any(), any()); CacheHandle entry = cache.lookup(simpleSpawn, simplePolicy); assertThat(entry.hasResult()).isFalse(); diff --git a/src/test/java/com/google/devtools/build/lib/remote/RemoteSpawnRunnerTest.java b/src/test/java/com/google/devtools/build/lib/remote/RemoteSpawnRunnerTest.java index 1518a30fa911b6..0d3471b20b66da 100644 --- a/src/test/java/com/google/devtools/build/lib/remote/RemoteSpawnRunnerTest.java +++ b/src/test/java/com/google/devtools/build/lib/remote/RemoteSpawnRunnerTest.java @@ -379,6 +379,7 @@ public void treatFailedCachedActionAsCacheMiss_local() throws Exception { any(RemotePathResolver.class), any(ActionResult.class), eq(outErr), + any(), any()); } @@ -709,6 +710,7 @@ public void testNonHumanReadableServerLogsNotSaved() throws Exception { any(RemotePathResolver.class), eq(result), any(FileOutErr.class), + any(), any()); verify(cache, never()) .downloadFile(any(RemoteActionExecutionContext.class), any(Path.class), any(Digest.class)); @@ -749,6 +751,7 @@ public void testServerLogsNotSavedForSuccessfulAction() throws Exception { any(RemotePathResolver.class), eq(result), any(FileOutErr.class), + any(), any()); verify(cache, never()) .downloadFile(any(RemoteActionExecutionContext.class), any(Path.class), any(Digest.class)); @@ -775,6 +778,7 @@ public void cacheDownloadFailureTriggersRemoteExecution() throws Exception { any(RemotePathResolver.class), eq(cachedResult), any(FileOutErr.class), + any(), any()); ActionResult execResult = ActionResult.newBuilder().setExitCode(31).build(); ExecuteResponse succeeded = ExecuteResponse.newBuilder().setResult(execResult).build(); @@ -790,6 +794,7 @@ public void cacheDownloadFailureTriggersRemoteExecution() throws Exception { any(RemotePathResolver.class), eq(execResult), any(FileOutErr.class), + any(), any()); Spawn spawn = newSimpleSpawn(); @@ -839,6 +844,7 @@ public void resultsDownloadFailureTriggersRemoteExecutionWithSkipCacheLookup() t any(RemotePathResolver.class), eq(cachedResult), any(FileOutErr.class), + any(), any()); doNothing() .when(cache) @@ -847,6 +853,7 @@ public void resultsDownloadFailureTriggersRemoteExecutionWithSkipCacheLookup() t any(RemotePathResolver.class), eq(execResult), any(FileOutErr.class), + any(), any()); Spawn spawn = newSimpleSpawn(); @@ -916,6 +923,7 @@ public void testRemoteExecutionTimeout() throws Exception { any(RemotePathResolver.class), eq(cachedResult), any(FileOutErr.class), + any(), any()); } @@ -966,6 +974,7 @@ public void testRemoteExecutionTimeoutDoesNotTriggerFallback() throws Exception any(RemotePathResolver.class), eq(cachedResult), any(FileOutErr.class), + any(), any()); verify(localRunner, never()).exec(eq(spawn), eq(policy)); } @@ -1011,6 +1020,7 @@ public void testRemoteExecutionCommandFailureDoesNotTriggerFallback() throws Exc any(RemotePathResolver.class), eq(cachedResult), any(FileOutErr.class), + any(), any()); verify(localRunner, never()).exec(eq(spawn), eq(policy)); } @@ -1177,6 +1187,7 @@ public void testDownloadMinimalOnCacheHit() throws Exception { any(RemotePathResolver.class), any(ActionResult.class), eq(outErr), + any(), any()); } @@ -1223,6 +1234,7 @@ public void testDownloadMinimalOnCacheMiss() throws Exception { any(RemotePathResolver.class), any(ActionResult.class), eq(outErr), + any(), any()); } @@ -1275,6 +1287,7 @@ public void testDownloadMinimalIoError() throws Exception { any(RemotePathResolver.class), any(ActionResult.class), eq(outErr), + any(), any()); } @@ -1306,7 +1319,7 @@ public void testDownloadTopLevel() throws Exception { assertThat(result.status()).isEqualTo(Status.SUCCESS); // assert - verify(cache).download(any(), any(), eq(succeededAction), eq(outErr), any()); + verify(cache).download(any(), any(), eq(succeededAction), eq(outErr), any(), any()); verify(cache, never()) .downloadMinimal( any(), any(), eq(succeededAction), anyCollection(), any(), any(), any(), any()); diff --git a/src/test/java/com/google/devtools/build/lib/remote/util/UtilsTest.java b/src/test/java/com/google/devtools/build/lib/remote/util/UtilsTest.java new file mode 100644 index 00000000000000..7077baba9209d3 --- /dev/null +++ b/src/test/java/com/google/devtools/build/lib/remote/util/UtilsTest.java @@ -0,0 +1,39 @@ +// Copyright 2021 The Bazel Authors. All rights reserved. +// +// 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 com.google.devtools.build.lib.remote.util; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.devtools.build.lib.remote.util.Utils.bytesCountToDisplayString; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Tests for {@link Utils}. */ +@RunWith(JUnit4.class) +public class UtilsTest { + @Test + public void bytesCountToDisplayString_works() { + assertThat(bytesCountToDisplayString(1000)).isEqualTo("1000 B"); + assertThat(bytesCountToDisplayString(1 << 10)).isEqualTo("1 KiB"); + assertThat(bytesCountToDisplayString((1 << 10) + (1 << 10) / 10)).isEqualTo("1.1 KiB"); + assertThat(bytesCountToDisplayString(1 << 20)).isEqualTo("1 MiB"); + assertThat(bytesCountToDisplayString((1 << 20) + (1 << 20) / 10)).isEqualTo("1.1 MiB"); + assertThat(bytesCountToDisplayString(1 << 30)).isEqualTo("1 GiB"); + assertThat(bytesCountToDisplayString((1 << 30) + (1 << 30) / 10)).isEqualTo("1.1 GiB"); + assertThat(bytesCountToDisplayString(1L << 40)).isEqualTo("1 TiB"); + assertThat(bytesCountToDisplayString((1L << 40) + (1L << 40) / 10)).isEqualTo("1.1 TiB"); + assertThat(bytesCountToDisplayString(1L << 50)).isEqualTo("1024 TiB"); + } +} diff --git a/src/test/java/com/google/devtools/build/lib/runtime/UiStateTrackerTest.java b/src/test/java/com/google/devtools/build/lib/runtime/UiStateTrackerTest.java index 4eca3a71505017..197ac415e72114 100644 --- a/src/test/java/com/google/devtools/build/lib/runtime/UiStateTrackerTest.java +++ b/src/test/java/com/google/devtools/build/lib/runtime/UiStateTrackerTest.java @@ -29,6 +29,7 @@ import com.google.devtools.build.lib.actions.ActionCompletionEvent; import com.google.devtools.build.lib.actions.ActionLookupData; import com.google.devtools.build.lib.actions.ActionOwner; +import com.google.devtools.build.lib.actions.ActionProgressEvent; import com.google.devtools.build.lib.actions.ActionStartedEvent; import com.google.devtools.build.lib.actions.Artifact; import com.google.devtools.build.lib.actions.ArtifactRoot; @@ -583,6 +584,92 @@ public void testActionStrategyVisible() throws Exception { .isTrue(); } + private Action createDummyAction(String progressMessage) { + String primaryOutput = "some/path/to/a/file"; + Path path = outputBase.getRelative(PathFragment.create(primaryOutput)); + Artifact artifact = + ActionsTestUtil.createArtifact(ArtifactRoot.asSourceRoot(Root.fromPath(outputBase)), path); + Action action = mockAction(progressMessage, primaryOutput); + when(action.getOwner()).thenReturn(mock(ActionOwner.class)); + when(action.getPrimaryOutput()).thenReturn(artifact); + return action; + } + + @Test + public void actionProgress_visible() throws Exception { + // arrange + ManualClock clock = new ManualClock(); + Action action = createDummyAction("Some random action"); + UiStateTracker stateTracker = new UiStateTracker(clock, /* targetWidth= */ 70); + stateTracker.actionProgress( + ActionProgressEvent.create(action, "action-id", "action progress", false)); + LoggingTerminalWriter terminalWriter = new LoggingTerminalWriter(/*discardHighlight=*/ true); + + // act + stateTracker.writeProgressBar(terminalWriter); + + // assert + String output = terminalWriter.getTranscript(); + assertThat(output).contains("action progress"); + } + + @Test + public void actionProgress_withTooSmallWidth_progressSkipped() throws Exception { + // arrange + ManualClock clock = new ManualClock(); + Action action = createDummyAction("Some random action"); + UiStateTracker stateTracker = new UiStateTracker(clock, /* targetWidth= */ 30); + stateTracker.actionProgress( + ActionProgressEvent.create(action, "action-id", "action progress", false)); + LoggingTerminalWriter terminalWriter = new LoggingTerminalWriter(/*discardHighlight=*/ true); + + // act + stateTracker.writeProgressBar(terminalWriter); + + // assert + String output = terminalWriter.getTranscript(); + assertThat(output).doesNotContain("action progress"); + } + + @Test + public void actionProgress_withSmallWidth_progressShortened() throws Exception { + // arrange + ManualClock clock = new ManualClock(); + Action action = createDummyAction("Some random action"); + UiStateTracker stateTracker = new UiStateTracker(clock, /* targetWidth= */ 50); + stateTracker.actionProgress( + ActionProgressEvent.create(action, "action-id", "action progress", false)); + LoggingTerminalWriter terminalWriter = new LoggingTerminalWriter(/*discardHighlight=*/ true); + + // act + stateTracker.writeProgressBar(terminalWriter); + + // assert + String output = terminalWriter.getTranscript(); + assertThat(output).contains("action pro..."); + } + + @Test + public void actionProgress_multipleProgress_displayInOrder() throws Exception { + // arrange + ManualClock clock = new ManualClock(); + Action action = createDummyAction("Some random action"); + UiStateTracker stateTracker = new UiStateTracker(clock, /* targetWidth= */ 70); + stateTracker.actionProgress( + ActionProgressEvent.create(action, "action-id1", "action progress 1", false)); + stateTracker.actionProgress( + ActionProgressEvent.create(action, "action-id2", "action progress 2", false)); + LoggingTerminalWriter terminalWriter = new LoggingTerminalWriter(/*discardHighlight=*/ true); + + // act + stateTracker.writeProgressBar(terminalWriter); + + // assert + String output = terminalWriter.getTranscript(); + assertThat(output).contains("action progress 1"); + assertThat(output).doesNotContain("action progress 2"); + } + @Test public void testMultipleActionStrategiesVisibleForDynamicScheduling() throws Exception { String strategy1 = "strategy1";