1 // Copyright 2023 The Chromium Authors 2 // Use of this source code is governed by a BSD-style license that can be 3 // found in the LICENSE file. 4 5 package org.chromium.base.test.transit; 6 7 import android.util.ArrayMap; 8 import android.util.Pair; 9 10 import androidx.annotation.IntDef; 11 12 import org.chromium.base.Log; 13 import org.chromium.base.ThreadUtils; 14 import org.chromium.base.TimeUtils; 15 import org.chromium.base.test.util.CriteriaHelper; 16 import org.chromium.base.test.util.CriteriaNotSatisfiedException; 17 18 import java.lang.annotation.Retention; 19 import java.lang.annotation.RetentionPolicy; 20 import java.util.List; 21 import java.util.Map; 22 23 /** Waits for multiple {@link ConditionWaitStatus}es, polling the {@link Condition}s in parallel. */ 24 public class ConditionWaiter { 25 26 /** 27 * The fulfillment status of a {@link Condition} being waited for. 28 * 29 * <p>Tracks the times at which the Condition was checked to provide information about how long 30 * it took to be fulfilled (or for long it was checked until it timed out). 31 * 32 * <p>Tracks and aggregates errors thrown during the Condition checking for user-friendly 33 * printing. 34 */ 35 static class ConditionWaitStatus { 36 37 private final Condition mCondition; 38 private final @ConditionOrigin int mOrigin; 39 private long mTimeStarted; 40 private long mTimeUnfulfilled; 41 private long mTimeFulfilled; 42 private ArrayMap<String, Integer> mErrors = new ArrayMap<>(); 43 44 /** 45 * Constructor. 46 * 47 * @param condition the {@link Condition} that this will hold the status for. 48 * @param origin the origin of the |condition|. 49 */ ConditionWaitStatus(Condition condition, @ConditionOrigin int origin)50 ConditionWaitStatus(Condition condition, @ConditionOrigin int origin) { 51 mCondition = condition; 52 mOrigin = origin; 53 } 54 startTimer()55 private void startTimer() { 56 mTimeStarted = getNow(); 57 mTimeUnfulfilled = mTimeStarted; 58 } 59 update()60 private boolean update() { 61 try { 62 boolean fulfilled; 63 if (mCondition.isRunOnUiThread()) { 64 // TODO(crbug.com/1489445): Post multiple checks in parallel, the UI thread will 65 // run them sequentially. 66 fulfilled = ThreadUtils.runOnUiThreadBlocking(mCondition::check); 67 } else { 68 fulfilled = mCondition.check(); 69 } 70 71 if (fulfilled) { 72 reportFulfilledWait(); 73 return false; 74 } else { 75 reportUnfulfilledWait(); 76 return true; 77 } 78 } catch (Exception e) { 79 reportError(e.getMessage()); 80 return true; 81 } 82 } 83 84 /** 85 * Report that the Condition being waited on is not fulfilled at this time. 86 * 87 * @throws IllegalStateException when the Condition is unfulfilled but it had previously 88 * been fulfilled. 89 */ reportUnfulfilledWait()90 private void reportUnfulfilledWait() throws IllegalStateException { 91 if (isFulfilled()) { 92 throw new IllegalStateException("Unfulfilled after already being fulfilled"); 93 } 94 95 mTimeUnfulfilled = getNow(); 96 } 97 98 /** Report that the Condition being waited on is fulfilled at this time. */ reportFulfilledWait()99 private void reportFulfilledWait() { 100 if (!isFulfilled()) { 101 // isFulfilled() will return true after setting a non-zero time. 102 mTimeFulfilled = getNow(); 103 } 104 } 105 106 /** 107 * Report that an error happened when checking the Condition. 108 * 109 * @param reason a String that will be printed as the reason; errors with the exact same 110 * reason are aggregated. 111 */ reportError(String reason)112 private void reportError(String reason) { 113 int beforeCount = mErrors.getOrDefault(reason, 0); 114 mErrors.put(reason, beforeCount + 1); 115 } 116 117 /** 118 * @return if the Condition is fulfilled. 119 */ isFulfilled()120 private boolean isFulfilled() { 121 return mTimeFulfilled > 0; 122 } 123 124 /** 125 * @return how long the condition has been considered unfulfilled for. 126 * <p>The Condition must be unfulfilled, or an assertion will be raised. 127 */ getTimeUnfulfilled()128 private long getTimeUnfulfilled() { 129 assert !isFulfilled(); 130 131 return mTimeUnfulfilled - mTimeStarted; 132 } 133 134 /** 135 * @return how long the condition took to be fulfilled for the first time. The result is a 136 * pair (lowerBound, upperBound), where the time it took is between these two numbers. 137 * |lowerBound| is the last time at which the Condition was seen as unfulfilled and 138 * |upperBound| is the first time at which the Condition was seen as fulfilled. 139 * <p>The Condition must be fulfilled, or an assertion will be raised. 140 */ getTimeToFulfill()141 private Pair<Long, Long> getTimeToFulfill() { 142 assert isFulfilled(); 143 144 long minTimeToFulfill = mTimeUnfulfilled - mTimeStarted; 145 long maxTimeToFulfill = mTimeFulfilled - mTimeStarted; 146 return Pair.create(minTimeToFulfill, maxTimeToFulfill); 147 } 148 149 /** 150 * @return an aggegation of the errors reported while checking a Condition or reporting its 151 * status. 152 */ getErrors()153 private Map<String, Integer> getErrors() { 154 return mErrors; 155 } 156 getNow()157 private static long getNow() { 158 long now = TimeUtils.currentTimeMillis(); 159 assert now > 0; 160 return now; 161 } 162 } 163 164 /** The maximum time to wait for a criteria to become valid. */ 165 public static final long MAX_TIME_TO_POLL = 3000L; 166 167 /** The polling interval to wait between checking for a satisfied criteria. */ 168 public static final long POLLING_INTERVAL = 50; 169 170 private static final String TAG = "Transit"; 171 172 /** 173 * Blocks waiting for multiple {@link ConditionWaitStatus}es, polling the {@link Condition}s in 174 * parallel and reporting their status to the {@link ConditionWaitStatus}es. 175 * 176 * <p>The timeout is |MAX_TIME_TO_POLL|. 177 * 178 * <p>TODO(crbug.com/1489462): Make the timeout configurable per transition. 179 * 180 * @param conditionStatuses the {@link ConditionWaitStatus}es to wait for. 181 * @throws AssertionError if not all {@link Condition}s are fulfilled before timing out. 182 */ waitFor(List<ConditionWaitStatus> conditionStatuses)183 public static void waitFor(List<ConditionWaitStatus> conditionStatuses) { 184 if (conditionStatuses.isEmpty()) { 185 Log.i(TAG, "No conditions to fulfill."); 186 } 187 188 for (ConditionWaitStatus status : conditionStatuses) { 189 status.startTimer(); 190 } 191 192 Runnable checker = 193 () -> { 194 boolean anyCriteriaMissing = false; 195 for (ConditionWaitStatus status : conditionStatuses) { 196 anyCriteriaMissing |= status.update(); 197 } 198 199 if (anyCriteriaMissing) { 200 throw buildWaitConditionsException(conditionStatuses); 201 } else { 202 Log.i( 203 TAG, 204 "Conditions fulfilled:\n%s", 205 createWaitConditionsSummary(conditionStatuses)); 206 } 207 }; 208 209 CriteriaHelper.pollInstrumentationThread(checker, MAX_TIME_TO_POLL, POLLING_INTERVAL); 210 } 211 buildWaitConditionsException( List<ConditionWaitStatus> conditionStatuses)212 private static CriteriaNotSatisfiedException buildWaitConditionsException( 213 List<ConditionWaitStatus> conditionStatuses) { 214 return new CriteriaNotSatisfiedException( 215 "Did not meet all conditions:\n" + createWaitConditionsSummary(conditionStatuses)); 216 } 217 createWaitConditionsSummary(List<ConditionWaitStatus> conditionStatuses)218 private static String createWaitConditionsSummary(List<ConditionWaitStatus> conditionStatuses) { 219 StringBuilder detailsString = new StringBuilder(); 220 221 int i = 1; 222 for (ConditionWaitStatus conditionStatus : conditionStatuses) { 223 String conditionDescription = conditionStatus.mCondition.getDescription(); 224 225 String originString = ""; 226 switch (conditionStatus.mOrigin) { 227 case ConditionOrigin.ENTER: 228 originString = "[ENTER]"; 229 break; 230 case ConditionOrigin.EXIT: 231 originString = "[EXIT ]"; 232 break; 233 case ConditionOrigin.TRANSITION: 234 originString = "[TRSTN]"; 235 break; 236 } 237 238 Map<String, Integer> errors = conditionStatus.getErrors(); 239 StringBuilder errorsString = new StringBuilder(); 240 String statusString; 241 if (!errors.isEmpty()) { 242 errorsString.append(" {errors: "); 243 for (Map.Entry<String, Integer> e : errors.entrySet()) { 244 String errorReason = e.getKey(); 245 Integer errorCount = e.getValue(); 246 errorsString.append(String.format("%s (%d errors);", errorReason, errorCount)); 247 } 248 errorsString.append("}"); 249 statusString = "[ERR ]"; 250 } else if (conditionStatus.isFulfilled()) { 251 statusString = "[OK ]"; 252 } else { 253 statusString = "[FAIL]"; 254 } 255 256 String fulfilledString; 257 if (conditionStatus.isFulfilled()) { 258 Pair<Long, Long> timeToFulfill = conditionStatus.getTimeToFulfill(); 259 fulfilledString = 260 String.format( 261 "{fulfilled after %d~%d ms}", 262 timeToFulfill.first, timeToFulfill.second); 263 } else { 264 fulfilledString = 265 String.format( 266 "{unfulfilled after %d ms}", conditionStatus.getTimeUnfulfilled()); 267 } 268 269 detailsString 270 .append(" [") 271 .append(i) 272 .append("] ") 273 .append(originString) 274 .append(" ") 275 .append(statusString) 276 .append(" ") 277 .append(conditionDescription) 278 .append(" ") 279 .append(fulfilledString); 280 if (errorsString.length() > 0) { 281 detailsString.append(" ").append(errorsString); 282 } 283 detailsString.append('\n'); 284 i++; 285 } 286 return detailsString.toString(); 287 } 288 289 /** The origin of a {@link Condition} (enter, exit, transition). */ 290 @IntDef({ 291 ConditionWaiter.ConditionOrigin.ENTER, 292 ConditionWaiter.ConditionOrigin.EXIT, 293 ConditionWaiter.ConditionOrigin.TRANSITION 294 }) 295 @Retention(RetentionPolicy.SOURCE) 296 @interface ConditionOrigin { 297 int ENTER = 0; 298 int EXIT = 1; 299 int TRANSITION = 2; 300 } 301 } 302