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