1 /* 2 * Copyright (C) 2018 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package android.platform.test.longevity; 17 18 import static java.util.stream.Collectors.joining; 19 import static java.util.stream.Collectors.toList; 20 import static java.util.stream.Collectors.toMap; 21 22 import android.content.res.AssetManager; 23 import android.os.Bundle; 24 import android.os.SystemClock; 25 import android.platform.test.longevity.proto.Configuration; 26 import android.platform.test.longevity.proto.Configuration.Scenario; 27 import android.platform.test.longevity.proto.Configuration.Schedule; 28 import android.util.Log; 29 30 import androidx.annotation.VisibleForTesting; 31 import androidx.test.InstrumentationRegistry; 32 33 import org.junit.runner.Description; 34 import org.junit.runner.Runner; 35 import org.junit.runner.notification.RunListener; 36 37 import java.io.File; 38 import java.io.FileInputStream; 39 import java.io.IOException; 40 import java.io.InputStream; 41 import java.text.ParseException; 42 import java.text.SimpleDateFormat; 43 import java.util.ArrayList; 44 import java.util.Collections; 45 import java.util.Comparator; 46 import java.util.List; 47 import java.util.Map; 48 import java.util.TimeZone; 49 import java.util.function.Function; 50 51 /** A profile composer for device-side testing. */ 52 public class Profile extends RunListener { 53 @VisibleForTesting static final String PROFILE_OPTION_NAME = "profile"; 54 55 protected static final String PROFILE_EXTENSION = ".pb"; 56 57 private static final String LOG_TAG = Profile.class.getSimpleName(); 58 59 // Parser for parsing "at" timestamps in profiles. 60 private static final SimpleDateFormat TIMESTAMP_FORMATTER = new SimpleDateFormat("HH:mm:ss"); 61 62 // Keeps track of the current scenario being run; updated at the end of a scenario. 63 private int mScenarioIndex = 0; 64 // A list of scenarios in the order that they will be run. 65 private List<Scenario> mOrderedScenariosList; 66 // Timestamp when the test run starts, defaults to time when the ProfileBase object is 67 // constructed. Can be overridden by {@link setTestRunStartTimeMs}. 68 private long mRunStartTimeMs = SystemClock.elapsedRealtime(); 69 // The profile configuration. 70 private final Configuration mConfiguration; 71 // The timestamp of the first scenario in milliseconds. All scenarios will be scheduled relative 72 // to this timestamp. 73 private long mFirstScenarioTimestampMs = 0; 74 75 // Comparator for sorting timestamped CUJs. 76 private static class ScenarioTimestampComparator implements Comparator<Scenario> { 77 @Override compare(Scenario s1, Scenario s2)78 public int compare(Scenario s1, Scenario s2) { 79 if (!(s1.hasAt() && s2.hasAt())) { 80 throw new IllegalArgumentException( 81 "Scenarios in scheduled profiles must have timestamps."); 82 } 83 return s1.getAt().compareTo(s2.getAt()); 84 } 85 } 86 87 // Comparator for sorting indexed CUJs. 88 private static class ScenarioIndexedComparator implements Comparator<Scenario> { 89 @Override compare(Scenario s1, Scenario s2)90 public int compare(Scenario s1, Scenario s2) { 91 if (!(s1.hasIndex() && s2.hasIndex())) { 92 throw new IllegalArgumentException( 93 "Scenarios in indexed profiles must have indexes."); 94 } 95 return Integer.compare(s1.getIndex(), s2.getIndex()); 96 } 97 } 98 Profile(Bundle args)99 public Profile(Bundle args) { 100 super(); 101 // Set the timestamp parser to UTC to get test timestamps as "time elapsed since zero". 102 TIMESTAMP_FORMATTER.setTimeZone(TimeZone.getTimeZone("UTC")); 103 104 // Load configuration from arguments and stored the list of scenarios sorted according to 105 // their timestamps. 106 mConfiguration = getConfigurationArgument(args); 107 // When no configuration is supplied, behaves the same way as LongevitySuite but without 108 // support for shuffle, iterate etc. 109 if (mConfiguration == null) { 110 return; 111 } 112 final List<Scenario> orderedScenarios = new ArrayList<>(mConfiguration.getScenariosList()); 113 if (orderedScenarios.isEmpty()) { 114 throw new IllegalArgumentException("Profile must have at least one scenario."); 115 } 116 if (mConfiguration.getSchedule().equals(Schedule.TIMESTAMPED)) { 117 if (mConfiguration.getRepetitions() != 1) { 118 throw new IllegalArgumentException( 119 "Repetitions param not supported for TIMESTAMPED scheduler"); 120 } 121 122 Collections.sort(orderedScenarios, new ScenarioTimestampComparator()); 123 try { 124 mFirstScenarioTimestampMs = 125 TIMESTAMP_FORMATTER.parse(orderedScenarios.get(0).getAt()).getTime(); 126 } catch (ParseException e) { 127 throw new IllegalArgumentException( 128 "Cannot parse the timestamp of the first scenario.", e); 129 } 130 } else if (mConfiguration.getSchedule().equals(Schedule.INDEXED)) { 131 Collections.sort(orderedScenarios, new ScenarioIndexedComparator()); 132 } else if (mConfiguration.getSchedule().equals(Schedule.SEQUENTIAL)) { 133 // Do nothing. Rely on the natural ordering specified in the profile. 134 } else { 135 throw new UnsupportedOperationException( 136 "Only scheduled profiles are currently supported."); 137 } 138 139 mOrderedScenariosList = new ArrayList<>(); 140 for (int i = 0; i < mConfiguration.getRepetitions(); i++) { 141 mOrderedScenariosList.addAll(orderedScenarios); 142 } 143 } 144 getRunnerSequence(List<Runner> input)145 public List<Runner> getRunnerSequence(List<Runner> input) { 146 if (mConfiguration == null) { 147 return input; 148 } 149 return getTestSequenceFromConfiguration(mConfiguration, input); 150 } 151 getTestSequenceFromConfiguration( Configuration config, List<Runner> input)152 protected List<Runner> getTestSequenceFromConfiguration( 153 Configuration config, List<Runner> input) { 154 Map<String, Runner> nameToRunner = 155 input.stream() 156 .collect( 157 toMap( 158 r -> r.getDescription().getDisplayName(), 159 Function.identity())); 160 Log.i( 161 LOG_TAG, 162 String.format( 163 "Available journeys: %s", 164 nameToRunner.keySet().stream().collect(joining(", ")))); 165 List<Runner> result = 166 mOrderedScenariosList 167 .stream() 168 .map(Configuration.Scenario::getJourney) 169 .map( 170 journeyName -> { 171 if (nameToRunner.containsKey(journeyName)) { 172 return nameToRunner.get(journeyName); 173 } else { 174 // Write error message here to trick the auto-formatter. 175 String errorFmtMessage = 176 "Journey %s in profile not found. " 177 + "Check logcat to see available journeys."; 178 throw new IllegalArgumentException( 179 String.format(errorFmtMessage, journeyName)); 180 } 181 }) 182 .collect(toList()); 183 Log.i( 184 LOG_TAG, 185 String.format( 186 "Returned runners: %s", 187 result.stream() 188 .map(Runner::getDescription) 189 .map(Description::getDisplayName) 190 .collect(toList()))); 191 return result; 192 } 193 194 @Override testRunStarted(Description description)195 public void testRunStarted(Description description) { 196 mRunStartTimeMs = SystemClock.elapsedRealtime(); 197 } 198 199 @Override testFinished(Description description)200 public void testFinished(Description description) { 201 // Increments the index to move onto the next scenario. 202 mScenarioIndex += 1; 203 } 204 205 @Override testIgnored(Description description)206 public void testIgnored(Description description) { 207 // Increments the index to move onto the next scenario. 208 mScenarioIndex += 1; 209 } 210 211 /** 212 * Returns true if there is a next scheduled scenario to run. If no profile is supplied, returns 213 * false. 214 */ hasNextScheduledScenario()215 public boolean hasNextScheduledScenario() { 216 return (mOrderedScenariosList != null) 217 && (mScenarioIndex < mOrderedScenariosList.size() - 1); 218 } 219 220 /** Returns time in milliseconds until the next scenario. */ getTimeUntilNextScenarioMs()221 public long getTimeUntilNextScenarioMs() { 222 Scenario nextScenario = mOrderedScenariosList.get(mScenarioIndex + 1); 223 if (nextScenario.hasAt()) { 224 try { 225 // Calibrate the start time against the first scenario's timestamp. 226 long startTimeMs = 227 TIMESTAMP_FORMATTER.parse(nextScenario.getAt()).getTime() 228 - mFirstScenarioTimestampMs; 229 // Time in milliseconds from the start of the test run to the current point in time. 230 long currentTimeMs = getTimeSinceRunStartedMs(); 231 // If the next test should not start yet, sleep until its start time. Otherwise, 232 // start it immediately. 233 if (startTimeMs > currentTimeMs) { 234 return startTimeMs - currentTimeMs; 235 } 236 } catch (ParseException e) { 237 throw new IllegalArgumentException( 238 String.format( 239 "Timestamp %s from scenario %s could not be parsed", 240 nextScenario.getAt(), nextScenario.getJourney())); 241 } 242 } 243 // For non-scheduled profiles (not a priority at this point), simply return 0. 244 return 0L; 245 } 246 247 /** Return time in milliseconds since the test run started. */ getTimeSinceRunStartedMs()248 public long getTimeSinceRunStartedMs() { 249 return SystemClock.elapsedRealtime() - mRunStartTimeMs; 250 } 251 252 /** Returns the Scenario object for the current scenario. */ getCurrentScenario()253 public Scenario getCurrentScenario() { 254 return mOrderedScenariosList.get(mScenarioIndex); 255 } 256 257 /** Returns the profile configuration. */ getConfiguration()258 public Configuration getConfiguration() { 259 return mConfiguration; 260 } 261 262 /* 263 * Parses the arguments, reads the configuration file and returns the Configuration object. 264 * 265 * If no profile option is found in the arguments, function should return null, in which case 266 * the input sequence is returned without modification. Otherwise, function should parse the 267 * profile according to the supplied argument and return the Configuration object or throw an 268 * exception if the file is not available or cannot be parsed. 269 * 270 * The configuration should be passed as either the name of a configuration bundled into the APK 271 * or a path to the configuration file. 272 * 273 * TODO(harrytczhang@): Write tests for this logic. 274 */ getConfigurationArgument(Bundle args)275 protected Configuration getConfigurationArgument(Bundle args) { 276 // profileValue is either the name of a profile bundled with an APK or a path to a 277 // profile configuration file. 278 String profileValue = args.getString(PROFILE_OPTION_NAME, ""); 279 if (profileValue.isEmpty()) { 280 return null; 281 } 282 // Look inside the APK assets for the profile; if this fails, try 283 // using the profile argument as a path to a configuration file. 284 InputStream configStream; 285 try { 286 AssetManager manager = InstrumentationRegistry.getContext().getAssets(); 287 String profileName = profileValue + PROFILE_EXTENSION; 288 configStream = manager.open(profileName); 289 } catch (IOException e) { 290 // Try using the profile argument it as a path to a configuration file. 291 try { 292 File configFile = new File(profileValue); 293 if (!configFile.exists()) { 294 throw new IllegalArgumentException(String.format( 295 "Profile %s does not exist.", profileValue)); 296 } 297 configStream = new FileInputStream(configFile); 298 } catch (IOException f) { 299 throw new IllegalArgumentException(String.format( 300 "Profile %s cannot be opened.", profileValue)); 301 } 302 } 303 try { 304 // Parse the configuration from its input stream and return it. 305 return Configuration.parseFrom(configStream); 306 } catch (IOException e) { 307 throw new IllegalArgumentException(String.format( 308 "Cannot parse profile %s.", profileValue)); 309 } finally { 310 try { 311 configStream.close(); 312 } catch (IOException e) { 313 throw new RuntimeException(e); 314 } 315 } 316 } 317 } 318