RunnerArgs.java

/*
 * Copyright (C) 2016 The Android Open Source Project
 *
 * 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 androidx.test.internal.runner;

import android.app.Instrumentation;
import android.content.pm.InstrumentationInfo;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.support.annotation.VisibleForTesting;
import android.text.TextUtils;
import android.util.Log;
import androidx.test.runner.lifecycle.ApplicationLifecycleCallback;
import androidx.test.runner.screenshot.ScreenCaptureProcessor;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.junit.runner.manipulation.Filter;
import org.junit.runner.notification.RunListener;
import org.junit.runners.model.RunnerBuilder;

/** Contains input arguments passed to the instrumentation test runner. */
public class RunnerArgs {
  private static final String LOG_TAG = "RunnerArgs";

  // constants for supported instrumentation arguments
  static final String ARGUMENT_TEST_CLASS = "class";
  static final String ARGUMENT_CLASSPATH_TO_SCAN = "classpathToScan";
  static final String ARGUMENT_NOT_TEST_CLASS = "notClass";
  static final String ARGUMENT_TEST_SIZE = "size";
  static final String ARGUMENT_LOG_ONLY = "log";
  static final String ARGUMENT_ANNOTATION = "annotation";
  static final String ARGUMENT_NOT_ANNOTATION = "notAnnotation";
  static final String ARGUMENT_NUM_SHARDS = "numShards";
  static final String ARGUMENT_SHARD_INDEX = "shardIndex";
  static final String ARGUMENT_DELAY_IN_MILLIS = "delay_msec";
  static final String ARGUMENT_COVERAGE = "coverage";
  static final String ARGUMENT_COVERAGE_PATH = "coverageFile";
  static final String ARGUMENT_SUITE_ASSIGNMENT = "suiteAssignment";
  static final String ARGUMENT_DEBUG = "debug";
  static final String ARGUMENT_LISTENER = "listener";
  static final String ARGUMENT_FILTER = "filter";
  static final String ARGUMENT_RUNNER_BUILDER = "runnerBuilder";
  static final String ARGUMENT_TEST_PACKAGE = "package";
  static final String ARGUMENT_NOT_TEST_PACKAGE = "notPackage";
  static final String ARGUMENT_TIMEOUT = "timeout_msec";
  static final String ARGUMENT_TEST_FILE = "testFile";
  static final String ARGUMENT_NOT_TEST_FILE = "notTestFile";
  static final String ARGUMENT_DISABLE_ANALYTICS = "disableAnalytics";
  static final String ARGUMENT_APP_LISTENER = "appListener";
  static final String ARGUMENT_CLASS_LOADER = "classLoader";
  static final String ARGUMENT_REMOTE_INIT_METHOD = "remoteMethod";
  static final String ARGUMENT_TARGET_PROCESS = "targetProcess";
  static final String ARGUMENT_SCREENSHOT_PROCESSORS = "screenCaptureProcessors";
  static final String ARGUMENT_ORCHESTRATOR_SERVICE = "orchestratorService";
  static final String ARGUMENT_LIST_TESTS_FOR_ORCHESTRATOR = "listTestsForOrchestrator";
  static final String ARGUMENT_SHELL_EXEC_BINDER_KEY = "shellExecBinderKey";
  static final String ARGUMENT_RUN_LISTENER_NEW_ORDER = "newRunListenerMode";

  // used to separate multiple fully-qualified test case class names
  private static final String CLASS_SEPARATOR = ",";
  // used to separate classpath entries
  private static final String CLASSPATH_SEPARATOR = ":";
  // used to separate fully-qualified test case class name, and one of its methods
  private static final char METHOD_SEPARATOR = '#';
  // pattern used to identify java class names conforming to java naming conventions
  private static final String CLASS_OR_METHOD_REGEX =
      "^([\p{L}_$][\p{L}\p{N}_$]*\.)*[\p{Lu}_$][\p{L}\p{N}_$]*(#[\p{L}_$][\p{L}\p{N}_$]*)?$";
  // pattern used to match valid java package names
  private static final String VALID_PACKAGE_REGEX =
      "^([\p{L}_$][\p{L}\p{N}_$]*\.)*[\p{L}_$][\p{L}\p{N}_$]*$";

  public final boolean debug;
  public final boolean suiteAssignment;
  public final boolean codeCoverage;
  public final String codeCoveragePath;
  public final int delayInMillis;
  public final boolean logOnly;
  public final List<String> testPackages;
  public final List<String> notTestPackages;
  public final String testSize;
  public final String annotation;
  public final List<String> notAnnotations;
  public final long testTimeout;
  public final List<RunListener> listeners;
  public final List<Filter> filters;
  public final List<Class<? extends RunnerBuilder>> runnerBuilderClasses;
  public final List<TestArg> tests;
  public final List<TestArg> notTests;
  public final int numShards;
  public final int shardIndex;
  public final boolean disableAnalytics;
  public final List<ApplicationLifecycleCallback> appListeners;
  public final ClassLoader classLoader;
  public final Set<String> classpathToScan;
  public final TestArg remoteMethod;
  public final String targetProcess;
  public final List<ScreenCaptureProcessor> screenCaptureProcessors;
  public final String orchestratorService;
  public final boolean listTestsForOrchestrator;
  public final String shellExecBinderKey;
  public final boolean newRunListenerMode;

  /** Encapsulates a test class and optional method. */
  public static class TestArg {
    public final String testClassName;
    public final String methodName;

    TestArg(String className, String methodName) {
      this.testClassName = className;
      this.methodName = methodName;
    }

    TestArg(String className) {
      this(className, null);
    }

    @Override
    public String toString() {
      return methodName != null ? testClassName + METHOD_SEPARATOR + methodName : testClassName;
    }
  }

  /** Encapsulates a list of test args and a list of package args found in a test file. */
  private static final class TestFileArgs {
    private final List<TestArg> tests = new ArrayList<>();
    private final List<String> packages = new ArrayList<>();
  }

  private RunnerArgs(Builder builder) {
    this.debug = builder.debug;
    this.suiteAssignment = builder.suiteAssignment;
    this.codeCoverage = builder.codeCoverage;
    this.codeCoveragePath = builder.codeCoveragePath;
    this.delayInMillis = builder.delayInMillis;
    this.logOnly = builder.logOnly;
    this.testPackages = builder.testPackages;
    this.notTestPackages = builder.notTestPackages;
    this.testSize = builder.testSize;
    this.annotation = builder.annotation;
    this.notAnnotations = Collections.unmodifiableList(builder.notAnnotations);
    this.testTimeout = builder.testTimeout;
    this.listeners = Collections.unmodifiableList(builder.listeners);
    this.filters = Collections.unmodifiableList(builder.filters);
    this.runnerBuilderClasses = Collections.unmodifiableList(builder.runnerBuilderClasses);
    this.tests = Collections.unmodifiableList(builder.tests);
    this.notTests = Collections.unmodifiableList(builder.notTests);
    this.numShards = builder.numShards;
    this.shardIndex = builder.shardIndex;
    this.disableAnalytics = builder.disableAnalytics;
    this.appListeners = Collections.unmodifiableList(builder.appListeners);
    this.classLoader = builder.classLoader;
    this.classpathToScan = builder.classpathToScan;
    this.remoteMethod = builder.remoteMethod;
    this.orchestratorService = builder.orchestratorService;
    this.listTestsForOrchestrator = builder.listTestsForOrchestrator;
    this.screenCaptureProcessors = Collections.unmodifiableList(builder.screenCaptureProcessors);
    this.targetProcess = builder.targetProcess;
    this.shellExecBinderKey = builder.shellExecBinderKey;
    this.newRunListenerMode = builder.newRunListenerMode;
  }

  public static class Builder {
    private boolean debug = false;
    private boolean suiteAssignment = false;
    private boolean codeCoverage = false;
    private String codeCoveragePath = null;
    private int delayInMillis = -1;
    private boolean logOnly = false;
    private List<String> testPackages = new ArrayList<>();
    private List<String> notTestPackages = new ArrayList<>();
    private String testSize = null;
    private String annotation = null;
    private List<String> notAnnotations = new ArrayList<String>();
    private long testTimeout = -1;
    private List<RunListener> listeners = new ArrayList<RunListener>();
    private List<Filter> filters = new ArrayList<>();
    private List<Class<? extends RunnerBuilder>> runnerBuilderClasses = new ArrayList<>();
    private List<TestArg> tests = new ArrayList<>();
    private List<TestArg> notTests = new ArrayList<>();
    private int numShards = 0;
    private int shardIndex = 0;
    private boolean disableAnalytics = false;
    private List<ApplicationLifecycleCallback> appListeners =
        new ArrayList<ApplicationLifecycleCallback>();
    private ClassLoader classLoader = null;
    private Set<String> classpathToScan = new HashSet<>();
    private TestArg remoteMethod = null;
    private String orchestratorService = null;
    private boolean listTestsForOrchestrator = false;
    private String targetProcess = null;
    private List<ScreenCaptureProcessor> screenCaptureProcessors = new ArrayList<>();
    public String shellExecBinderKey;
    private boolean newRunListenerMode = false;

    /**
     * Populate the arg data from the given Bundle.
     *
     * <p>Note: This will override any manifest-provided args
     */
    public Builder fromBundle(Bundle bundle) {
      this.debug = parseBoolean(bundle.getString(ARGUMENT_DEBUG));
      this.delayInMillis =
          parseUnsignedInt(bundle.get(ARGUMENT_DELAY_IN_MILLIS), ARGUMENT_DELAY_IN_MILLIS);
      // parse test class args
      this.tests.addAll(parseTestClasses(bundle.getString(ARGUMENT_TEST_CLASS)));
      this.notTests.addAll(parseTestClasses(bundle.getString(ARGUMENT_NOT_TEST_CLASS)));
      // parse test package args
      this.testPackages.addAll(parseTestPackages(bundle.getString(ARGUMENT_TEST_PACKAGE)));
      this.notTestPackages.addAll(parseTestPackages(bundle.getString(ARGUMENT_NOT_TEST_PACKAGE)));
      // parse test file args, which may include class and package args
      TestFileArgs testFileArgs = parseFromFile(bundle.getString(ARGUMENT_TEST_FILE));
      this.tests.addAll(testFileArgs.tests);
      this.testPackages.addAll(testFileArgs.packages);
      TestFileArgs notTestFileArgs = parseFromFile(bundle.getString(ARGUMENT_NOT_TEST_FILE));
      this.notTests.addAll(notTestFileArgs.tests);
      this.notTestPackages.addAll(notTestFileArgs.packages);
      this.listeners.addAll(
          parseLoadAndInstantiateClasses(
              bundle.getString(ARGUMENT_LISTENER), RunListener.class, null));
      this.filters.addAll(
          parseLoadAndInstantiateClasses(bundle.getString(ARGUMENT_FILTER), Filter.class, bundle));
      this.runnerBuilderClasses.addAll(
          parseAndLoadClasses(bundle.getString(ARGUMENT_RUNNER_BUILDER), RunnerBuilder.class));
      this.testSize = bundle.getString(ARGUMENT_TEST_SIZE);
      this.annotation = bundle.getString(ARGUMENT_ANNOTATION);
      this.notAnnotations.addAll(parseStrings(bundle.getString(ARGUMENT_NOT_ANNOTATION)));
      this.testTimeout = parseUnsignedLong(bundle.getString(ARGUMENT_TIMEOUT), ARGUMENT_TIMEOUT);
      this.numShards = parseUnsignedInt(bundle.get(ARGUMENT_NUM_SHARDS), ARGUMENT_NUM_SHARDS);
      this.shardIndex = parseUnsignedInt(bundle.get(ARGUMENT_SHARD_INDEX), ARGUMENT_SHARD_INDEX);
      this.logOnly = parseBoolean(bundle.getString(ARGUMENT_LOG_ONLY));
      this.disableAnalytics = parseBoolean(bundle.getString(ARGUMENT_DISABLE_ANALYTICS));
      this.appListeners.addAll(
          parseLoadAndInstantiateClasses(
              bundle.getString(ARGUMENT_APP_LISTENER), ApplicationLifecycleCallback.class, null));
      this.codeCoverage = parseBoolean(bundle.getString(ARGUMENT_COVERAGE));
      this.codeCoveragePath = bundle.getString(ARGUMENT_COVERAGE_PATH);
      this.suiteAssignment = parseBoolean(bundle.getString(ARGUMENT_SUITE_ASSIGNMENT));
      this.classLoader =
          parseLoadAndInstantiateClass(bundle.getString(ARGUMENT_CLASS_LOADER), ClassLoader.class);
      this.classpathToScan = parseClasspath(bundle.getString(ARGUMENT_CLASSPATH_TO_SCAN));
      if (bundle.containsKey(ARGUMENT_REMOTE_INIT_METHOD)) {
        this.remoteMethod = parseTestClass(bundle.getString(ARGUMENT_REMOTE_INIT_METHOD));
      }
      this.orchestratorService = bundle.getString(ARGUMENT_ORCHESTRATOR_SERVICE);
      this.listTestsForOrchestrator =
          parseBoolean(bundle.getString(ARGUMENT_LIST_TESTS_FOR_ORCHESTRATOR));
      this.targetProcess = bundle.getString(ARGUMENT_TARGET_PROCESS);
      this.screenCaptureProcessors.addAll(
          parseLoadAndInstantiateClasses(
              bundle.getString(ARGUMENT_SCREENSHOT_PROCESSORS),
              ScreenCaptureProcessor.class,
              null));
      this.shellExecBinderKey = bundle.getString(ARGUMENT_SHELL_EXEC_BINDER_KEY);
      this.newRunListenerMode = parseBoolean(bundle.getString(ARGUMENT_RUN_LISTENER_NEW_ORDER));
      return this;
    }

    /** Populate the arg data from the instrumentation:metadata attribute in Manifest. */
    public Builder fromManifest(Instrumentation instr) {
      PackageManager pm = instr.getContext().getPackageManager();
      try {
        InstrumentationInfo instrInfo =
            pm.getInstrumentationInfo(instr.getComponentName(), PackageManager.GET_META_DATA);
        Bundle b = instrInfo.metaData;
        if (b == null) {
          // metadata not present - skip
          return this;
        }
        // parse the metadata using same key names
        return fromBundle(b);
      } catch (PackageManager.NameNotFoundException e) {
        // should never happen
        Log.wtf(LOG_TAG, String.format("Could not find component %s", instr.getComponentName()));
      }
      return this;
    }

    /**
     * Utility method to split String element data in CSV format into a List.
     *
     * @return empty list if null input, otherwise list of strings
     */
    private static List<String> parseStrings(String value) {
      if (value == null) {
        return Collections.emptyList();
      }
      return Arrays.asList(value.split(","));
    }

    /**
     * Parse boolean value from a String.
     *
     * @return the boolean value, false on null input
     */
    private static boolean parseBoolean(String booleanValue) {
      return booleanValue != null && Boolean.parseBoolean(booleanValue);
    }

    /**
     * Parse int from given value - except either int or string.
     *
     * @return the value, -1 if not found
     * @throws NumberFormatException if value is negative or not a number
     */
    private static int parseUnsignedInt(Object value, String name) {
      if (value != null) {
        int intValue = Integer.parseInt(value.toString());
        if (intValue < 0) {
          throw new NumberFormatException(name + " can not be negative");
        }

        return intValue;
      }
      return -1;
    }

    /**
     * Parse long from given value - except either Long or String.
     *
     * @return the value, -1 if not found
     * @throws NumberFormatException if value is negative or not a number
     */
    private static long parseUnsignedLong(Object value, String name) {
      if (value != null) {
        long longValue = Long.parseLong(value.toString());
        if (longValue < 0) {
          throw new NumberFormatException(name + " can not be negative");
        }
        return longValue;
      }
      return -1;
    }

    /**
     * Parse test package data from given CSV data in the following format:
     * com.android.foo,com.android.bar,...
     *
     * @return list of package names, empty list if input is null
     */
    private static List<String> parseTestPackages(String packagesArg) {
      List<String> packages = new ArrayList<>();
      if (packagesArg != null) {
        for (String packageName : packagesArg.split(CLASS_SEPARATOR)) {
          packages.add(packageName);
        }
      }
      return packages;
    }

    /**
     * Parse test class and method data from given CSV data in following format:
     * com.TestClass1#method1,com.TestClass2,...
     *
     * @return list of {@link TestArg} data, empty list if input is null
     */
    private List<TestArg> parseTestClasses(String classesArg) {
      List<TestArg> tests = new ArrayList<TestArg>();
      if (classesArg != null) {
        for (String className : classesArg.split(CLASS_SEPARATOR)) {
          tests.add(parseTestClass(className));
        }
      }
      return tests;
    }

    /**
     * Parse classpath in the following format: {@code
     * /foo/class1.dex:/foo/class2.dex:/bar/class1.dex:...}
     *
     * @param classpath
     * @return {@link Set} of paths, empty list if input is {@code null}
     */
    private static Set<String> parseClasspath(String classpath) {
      if (classpath == null || classpath.isEmpty()) {
        return new HashSet<>();
      }
      return new HashSet<>(Arrays.asList(classpath.split(CLASSPATH_SEPARATOR, -1)));
    }

    /**
     * Parse an individual test class and optionally method from given string.
     *
     * <p>Expected format: com.TestClass1[#method1]
     */
    private static TestArg parseTestClass(String testClassName) {
      if (TextUtils.isEmpty(testClassName)) {
        return null;
      }
      int methodSeparatorIndex = testClassName.indexOf(METHOD_SEPARATOR);
      if (methodSeparatorIndex > 0) {
        String testMethodName = testClassName.substring(methodSeparatorIndex + 1);
        testClassName = testClassName.substring(0, methodSeparatorIndex);
        return new TestArg(testClassName, testMethodName);
      } else {
        return new TestArg(testClassName);
      }
    }

    /**
     * Parse and load the packages, classes and methods of a test file.
     *
     * @param filePath path to test file containing package names, full package names of test
     *     classes and optionally methods to add.
     */
    private TestFileArgs parseFromFile(String filePath) {
      TestFileArgs args = new TestFileArgs();
      if (filePath == null) {
        return args;
      }
      BufferedReader br = null;
      String line;
      try {
        br = new BufferedReader(new FileReader(new File(filePath)));
        while ((line = br.readLine()) != null) {
          if (isClassOrMethod(line)) {
            args.tests.add(parseTestClass(line));
          } else {
            // validate and parse test package
            args.packages.addAll(parseTestPackages(validatePackage(line)));
          }
        }
      } catch (FileNotFoundException e) {
        throw new IllegalArgumentException("testfile not found: " + filePath, e);
      } catch (IOException e) {
        throw new IllegalArgumentException("Could not read testfile " + filePath, e);
      } finally {
        if (br != null) {
          try {
            br.close();
          } catch (IOException e) {
            /* ignore */
          }
        }
      }
      return args;
    }

    /**
     * Determine whether line from test file represents a test class or method, as opposed to
     * package name.
     *
     * @param line string containing either an individual test class/method or a package name
     * @return true if line contains an individual test class or method
     */
    @VisibleForTesting
    static boolean isClassOrMethod(String line) {
      return line.matches(CLASS_OR_METHOD_REGEX);
    }

    /**
     * Throw an exception if the line from test file is not a valid package name. Providing an
     * invalid class name to this method will also result in an exception.
     *
     * @param line string containing an individual package name.
     * @throws ClassNotFoundException
     * @return line
     */
    @VisibleForTesting
    static String validatePackage(String line) {
      if (!line.matches(VALID_PACKAGE_REGEX)) {
        throw new IllegalArgumentException(
            String.format("\"%s\" not recognized as valid package name", line));
      }
      return line;
    }

    /**
     * Create a set of objects given a CSV string of full class names and type.
     *
     * @return the List of objects or empty list on null input
     */
    private <T> List<T> parseLoadAndInstantiateClasses(
        String classString, Class<T> type, Bundle bundle) {
      List<T> objects = new ArrayList<T>();
      if (classString != null) {
        for (String className : classString.split(CLASS_SEPARATOR)) {
          loadClassByNameInstantiateAndAdd(objects, className, type, bundle);
        }
      }
      return objects;
    }

    /**
     * Create an object of the given full class name.
     *
     * @return the object instance or null on null input
     */
    private <T> T parseLoadAndInstantiateClass(String classString, Class<T> type) {
      List<T> classLoaders = parseLoadAndInstantiateClasses(classString, type, null);
      if (!classLoaders.isEmpty()) {
        if (classLoaders.size() > 1) {
          throw new IllegalArgumentException(
              String.format("Expected 1 class loader, %d given", classLoaders.size()));
        }
        return classLoaders.get(0);
      }
      return null;
    }

    /**
     * Load class by supplied name, instantiate and add object to supplied list.
     *
     * <p>No effect if input is null or empty.
     *
     * @param objects the List to add to
     * @param className the fully qualified class name
     * @param bundle The bundle to pass to the constructor, null if no bundle is to be passed.
     * @throws IllegalArgumentException if listener cannot be loaded
     */
    private <T> void loadClassByNameInstantiateAndAdd(
        List<T> objects, String className, Class<T> type, Bundle bundle) {
      if (className == null || className.length() == 0) {
        return;
      }
      try {
        @SuppressWarnings("unchecked")
        final Class<? extends T> klass = (Class<? extends T>) Class.forName(className);
        Constructor<? extends T> constructor;
        Object[] arguments;

        // Look for the default constructor first to ensure backwards compatibility with
        // previous code.
        try {
          constructor = klass.getConstructor();
          arguments = new Object[0];
        } catch (NoSuchMethodException nsme1) {
          // Cannot find a default constructor so if a bundle is supplied then look for
          // one that takes a Bundle.
          if (bundle != null) {
            try {
              constructor = klass.getConstructor(Bundle.class);
              arguments = new Object[] {bundle};
            } catch (NoSuchMethodException nsme2) {
              // Could not find a constructor that takes a bundle so rethrow the
              // original exception, remembering to record that this exception was
              // suppressed.
              nsme2.initCause(nsme1);
              throw nsme2;
            }
          } else {
            // Rethrow exception as no bundle was provided.
            throw nsme1;
          }
        }
        constructor.setAccessible(true);
        @SuppressWarnings("unchecked")
        final T instance = constructor.newInstance(arguments);
        objects.add(instance);
      } catch (ClassNotFoundException e) {
        throw new IllegalArgumentException("Could not find extra class " + className);
      } catch (NoSuchMethodException e) {
        throw new IllegalArgumentException(
            "Must have no argument constructor for class " + className);
      } catch (ClassCastException e) {
        throw new IllegalArgumentException(className + " does not extend " + type.getName());
      } catch (InstantiationException e) {
        throw new IllegalArgumentException("Failed to create: " + className, e);
      } catch (InvocationTargetException e) {
        throw new IllegalArgumentException("Failed to create: " + className, e);
      } catch (IllegalAccessException e) {
        throw new IllegalArgumentException("Failed to create listener: " + className, e);
      }
    }

    /**
     * Create a set of classes given a CSV string of full class names and type.
     *
     * @return the List of classes or empty list on null input
     */
    private <T> List<Class<? extends T>> parseAndLoadClasses(String classString, Class<T> type) {
      List<Class<? extends T>> classes = new ArrayList<>();
      if (classString != null) {
        for (String className : classString.split(CLASS_SEPARATOR)) {
          loadClassByNameAndAdd(classes, className, type);
        }
      }
      return classes;
    }

    /**
     * Load class by supplied name and add to the supplied list.
     *
     * <p>No effect if input is null or empty.
     *
     * @param classes the List to add to
     * @param type the required ancestor of the class
     * @param className the fully qualified class name
     * @throws IllegalArgumentException if listener cannot be loaded
     */
    private <T> void loadClassByNameAndAdd(
        List<Class<? extends T>> classes, String className, Class<T> type) {
      if (null == className || className.length() == 0) {
        return;
      }
      try {
        Class<?> klass = Class.forName(className);
        if (!type.isAssignableFrom(klass)) {
          throw new IllegalArgumentException(className + " does not extend " + type.getName());
        }
        @SuppressWarnings("unchecked")
        Class<? extends T> castClass = (Class<? extends T>) klass;
        classes.add(castClass);
      } catch (ClassNotFoundException e) {
        throw new IllegalArgumentException("Could not find extra class " + className);
      } catch (ClassCastException e) {
        throw new IllegalArgumentException(className + " does not extend " + type.getName());
      }
    }

    public RunnerArgs build() {
      return new RunnerArgs(this);
    }
  }
}