/*
* The MIT License
*
* Copyright 2015 Tim Boudreau.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package com.mastfrog.testmain;
import com.google.common.reflect.ClassPath;
import com.mastfrog.settings.Settings;
import com.mastfrog.settings.SettingsBuilder;
import com.mastfrog.testmain.suites.SuiteLists;
import java.awt.EventQueue;
import java.awt.Font;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.io.IOException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.swing.BorderFactory;
import javax.swing.JLabel;
import javax.swing.JWindow;
import javax.swing.Timer;
import org.junit.Test;
import org.junit.runner.Description;
import org.junit.runner.JUnitCore;
import org.junit.runner.Result;
import org.junit.runner.notification.Failure;
import org.junit.runner.notification.RunListener;
/**
* A simple runner for projects which <i>are</i> a set of JUnit tests, with
* support for showing a window with the name of the current test, for use when
* capturing video from Selenium.
* <p>
* The following command-line arguments are relevant:
* <ul>
* <li>--tests [list of class names, comma sep] - explicitly run certain
* tests</li>
* <li>--packages [list of packages, comma sep] - list of packages to scan for
* classes
* </li>
* <li>--exclude [list of packages] - packages to exclude from scanning</li>
* </ul>
* The default behavior with no arguments is to scan the entire classpath for
* classes whose name ends in Test where at least one method has the @Test
* annotation. This works, but is slower than explicitly specifying classes.
* <p>
* A process exit code of 2 means tests failed.
* <p>
* All output from the test runner is prefixed by :: to make for easy filtering
* with grep or similar.
* <p>
* If you are using giulius-tests or giulius-selenium-tests, any unknown command
* line arguments will be set as system properties to ensure that @Named
* values are bound inside tests.
*
* @author Tim Boudreau
*/
public class TestMain {
// Avoid inadvertently
static String[] DEFAULT_EXCLUDED_PACKAGES = {
"junit.framework",
"com.sun.jna.platform.unix",
"org.apache.xalan.xsltc.compiler",
"org.bouncycastle.util.test",
"org.apache.xerces.impl.xpath", "javafx.scene", "junit.extensions",
"org.bouncycastle.util.test",
"org.apache.xpath.axes",
"com.mastfrog.giulius.tests",
"org.apache.xpath.patterns",
"org.junit", "jdk.internal.dynalink.beans",
"org.apache.regexp", "groovy.transform"
};
private static boolean showWindow;
public static void main(String[] args) throws IOException, ClassNotFoundException {
// Ensure that @Named values for within tests are set up including any command
// line arguments passed here
Settings settings = new SettingsBuilder().parseCommandLineArguments(args).build();
for (String key : settings.allKeys()) {
System.setProperty(key, settings.getString(key));
}
String testNamespace = System.getProperty("test.config", "tests");
Class<?>[] tests = findTests(testNamespace, args);
JUnitCore core = new JUnitCore();
core.addListener(new CmdLineOut());
Result result = core.run(tests);
// Pending - take screen shots on failure, use some reporting engine or other
System.out.println("::RAN: " + result.getRunCount());
System.out.println("::FAILURES: " + result.getFailureCount());
if (result.getFailureCount() > 0) {
for (Failure failure : result.getFailures()) {
System.out.println("::-> " + failure.getDescription());
}
}
System.out.println("::IGNORED:" + result.getIgnoreCount());
System.out.flush();
if (result.getFailureCount() > 0) {
System.exit(2);
}
}
private static Class<?>[] findTests(String testNamespace, String... args) throws IOException, ClassNotFoundException {
// Parse the command-line arguments and any system settings in /etc/tests.properties
Settings settings = new SettingsBuilder(testNamespace)
.addDefaultLocations()
.parseCommandLineArguments(args).build();
String suites = settings.getString("suites");
if (suites != null) {
String tests = settings.getString("test");
if (tests != null) {
throw new IOException("Cannot specify both --test and --suites");
}
SuiteLists known = new SuiteLists();
List<String> types = new LinkedList<>();
for (String suite : suites.split(",")) {
List<String> found = known.typeNames(suite);
if (found.isEmpty()) {
throw new IOException("No known suite named " + suite);
}
types.addAll(found);
}
if (types.isEmpty()) {
throw new IOException("No tests to run from " + suites);
}
StringBuilder sb = new StringBuilder();
for (Iterator<String> it = types.iterator(); it.hasNext();) {
sb.append(it.next());
if (it.hasNext()) {
sb.append(",");
}
}
settings = new SettingsBuilder().add(settings)
.add("test", sb.toString()).build();
}
// Determine if we should show a window with the test name, for video recording
showWindow = settings.getBoolean("test.window", true) && !Boolean.getBoolean("java.awt.headless");
// User provided individual test classes, e.g. --tests com.foo.Test1,com.foo.Test2
String individualTests = settings.getString("test");
if (individualTests != null) {
// Make sure no contradictory arguments
if (settings.getString("testPackages") != null) {
System.err.println("Pass either --tests or --testPackages, not both");
System.exit(3);
}
// Get the list of classes from the command line
Set<Class<?>> types = new LinkedHashSet<>();
for (String type : individualTests.split(",")) {
type = type.trim();
Class<?> clazz = Class.forName(type);
if (clazz.isLocalClass()) {
System.err.println(clazz.getName() + " cannot be instantiated");
System.exit(4);
}
if ((Modifier.ABSTRACT & clazz.getModifiers()) != 0) {
System.err.println(clazz.getName() + " is abstract");
System.exit(5);
}
types.add(clazz);
}
System.out.println("::TESTS: " + typesToString(types));
return types.toArray(new Class<?>[types.size()]);
} else {
String excludePackageNames = settings.getString("exclude");
Set<String> excluded = new HashSet<>();
if (excludePackageNames != null) {
for (String pkg : excludePackageNames.split(",")) {
pkg = pkg.trim();
excluded.add(pkg);
}
}
// We will scan packages for classes whose name ends with "Test"
String pkgs = settings.getString("packages");
Set<String> packages = pkgs == null ? Collections.<String>emptySet() : new HashSet<String>(Arrays.asList(pkgs.split(",")));
ClassPath pth = ClassPath.from(TestMain.class.getClassLoader());
Set<Class<?>> types = new LinkedHashSet<>();
NEXT_TYPE:
for (ClassPath.ClassInfo info : pth.getAllClasses()) {
String packageName = info.getPackageName();
// If the user passed e.g. --testPackages com.foo.bar,com.foo.baz
// then prune out anything that doesn't match
if (!packages.isEmpty()) {
for (String pkg : packages) {
if (!packageName.startsWith(pkg)) {
continue NEXT_TYPE;
}
}
} else {
for (String exc : excluded) {
if (packageName.startsWith(exc)) {
continue NEXT_TYPE;
}
}
// Since we're scanning the classpath, avoid picking up stuff
// from the JDK or libraries that happens to end with "Test"
for (String pkg : DEFAULT_EXCLUDED_PACKAGES) {
if (packageName.startsWith(pkg)) {
continue NEXT_TYPE;
}
}
// Ensure on other JDKs that obvious stuff isn't picked up
if (packageName.startsWith("java") || packageName.startsWith("javax") || packageName.startsWith("com.sun")) {
continue;
}
}
// Only include classes whose name ends in "Test"
if (info.getName().endsWith("Test")) {
Class<?> type = info.load();
// Weed out things that cannot possibly be usable
if (type.isLocalClass()) {
continue;
}
if ((type.getModifiers() & Modifier.ABSTRACT) != 0) {
continue;
}
boolean foundTestAnnotation = false;
for (Method m : type.getMethods()) {
if (m.getAnnotation(Test.class) != null) {
foundTestAnnotation = true;
break;
}
}
if (foundTestAnnotation && type.getAnnotation(Ignore.class) == null) { // allow base classes to be ignored
types.add(info.load());
}
}
}
if (types.isEmpty()) {
System.err.println("No test types found");
System.exit(1);
}
System.out.println("::TESTS: " + typesToString(types));
return types.toArray(new Class<?>[types.size()]);
}
}
static CharSequence typesToString(Iterable<Class<?>> types) {
StringBuilder sb = new StringBuilder();
for (Iterator<Class<?>> iter = types.iterator(); iter.hasNext();) {
sb.append(iter.next().getName());
if (iter.hasNext()) {
sb.append(", ");
}
}
return sb;
}
static class CmdLineOut extends RunListener {
// Continuous build simple reporting output like
// RUN: foo
// FAIL: foo
// SUCCESS: bar
private static final Pattern PAT = Pattern.compile(".*\\.(.*?\\..*?)$");
private String testName(Description description) {
String name = description.getClassName() + "." + description.getMethodName();
Matcher m = PAT.matcher(name);
if (m.find()) {
return m.group(1);
}
return name;
}
@Override
public void testStarted(Description description) throws Exception {
String name = testName(description);
showTest(name);
System.out.println("::RUN: " + name);
}
@Override
public void testAssumptionFailure(Failure failure) {
System.out.println("::FAIL: " + testName(failure.getDescription()));
failure.getException().printStackTrace(System.out);
}
@Override
public void testFinished(Description description) throws Exception {
System.out.println("::FINISHED: " + testName(description));
}
@Override
public void testFailure(Failure failure) throws Exception {
System.out.println("::FAIL: " + testName(failure.getDescription()));
failure.getException().printStackTrace(System.out);
}
}
private static void showTest(String testName) {
// Shows a window onscreen that names the test - this is needed
// when capturing video from Selenium tests
if (!showWindow) {
return;
}
final JWindow dlg = new JWindow();
final JLabel lbl = new JLabel(testName);
lbl.setBorder(BorderFactory.createEmptyBorder(20, 20, 20, 20));
lbl.setFont(new Font("Dialog", Font.BOLD, 42));
dlg.setContentPane(lbl);
class WL extends WindowAdapter implements ActionListener, Runnable {
private final Timer timer = new Timer(2000, this);
@Override
public void actionPerformed(ActionEvent ae) {
timer.stop();
EventQueue.invokeLater(this);
}
@Override
public void windowOpened(WindowEvent we) {
timer.start();
}
@Override
public void run() {
dlg.dispose();
}
}
dlg.addWindowListener(new WL());
dlg.pack();
dlg.setVisible(true);
}
}