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