package xapi.gwt.junit.impl; import org.junit.After; import org.junit.AfterClass; import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; import xapi.collect.X_Collect; import xapi.collect.api.ClassTo; import xapi.collect.api.IntTo; import xapi.gwt.junit.api.JUnitExecution; import xapi.log.X_Log; import xapi.time.X_Time; import xapi.util.X_Debug; import xapi.util.X_Runtime; import xapi.util.api.ReceivesValue; import com.google.gwt.core.client.GWT; import com.google.gwt.core.client.GWT.UncaughtExceptionHandler; import com.google.gwt.core.client.Scheduler.RepeatingCommand; import com.google.gwt.reflect.shared.GwtReflect; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.Collection; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.concurrent.Callable; import java.util.concurrent.TimeoutException; import java.util.function.Predicate; public class JUnit4Executor { public static final Throwable SUCCESS = new Throwable(); protected static JUnitExecution execution; private ClassTo<Lifecycle> lifecycles; protected JUnit4Executor() { if (execution == null) { execution = X_Runtime.isGwt() ? initializeSystem() : new JUnitExecution(); } lifecycles = X_Collect.newClassMap(Lifecycle.class); } public static Method[] findTests(final Class<?> testClass) throws Throwable { return new JUnit4Executor().findAnnotated(testClass); } private native JUnitExecution initializeSystem() /*-{ var originals = [setTimeout, setInterval, clearTimeout, clearInterval]; setTimeout = function () { var pid = originals[0].apply(this, arguments), exe = @JUnit4Executor::execution && @JUnit4Executor::execution.@JUnitExecution::timeouts; exe && ( exe[exe.length] = pid ); return pid; }; setInterval = function () { var pid = originals[1].apply(this, arguments), exe = @JUnit4Executor::execution && @JUnit4Executor::execution.@JUnitExecution::intervals; exe && ( exe[exe.length] = pid ); return pid; }; clearTimeout = function (pid) { originals[2].apply(this, arguments); var exe = @JUnit4Executor::execution && @JUnit4Executor::execution.@JUnitExecution::timeouts, ind = exe ? exe.indexOf(pid) : -1; ind !== -1 && exe.splice(ind, 1); }; clearInterval = function (pid) { originals[3].apply(this, arguments); var exe = @JUnit4Executor::execution && @JUnit4Executor::execution.@JUnitExecution::intervals, ind = exe ? exe.indexOf(pid) : -1; ind !== -1 && exe.splice(ind, 1); }; return @JUnitExecution::new()(); }-*/; private Method[] findAnnotated(final Class<?> testClass) { final Lifecycle lifecycle = new Lifecycle(testClass); return lifecycle.tests.values().toArray(new Method[0]); } protected void assertPublicZeroArgInstanceMethod(final Method method, final Class<?> type) { assertPublicZeroArgMethod(method, type); if (Modifier.isStatic(method.getModifiers())) { throw new AssertionError("@" + type.getSimpleName() + " methods must not be static"); } } protected void assertPublicZeroArgMethod(final Method method, final Class<?> type) { if (!Modifier.isPublic(method.getModifiers())) { throw new AssertionError("@" + type.getSimpleName() + " methods must be public"); } if (0 != method.getParameterTypes().length) { throw new AssertionError("@" + type.getSimpleName() + " methods must be zero-arg"); } } protected void assertPublicZeroArgStaticMethod(final Method method, final Class<?> type) { assertPublicZeroArgMethod(method, type); if (!Modifier.isStatic(method.getModifiers())) { throw new AssertionError("@" + type.getSimpleName() + " methods must be static"); } } protected void debug(final String string, Throwable e) { if (GWT.isProdMode()) { GWT.log(string + " (" + e + ")"); } else { System.out.println(string); } while (e != null) { e.printStackTrace(System.err); e = e.getCause(); } } protected ReceivesValue<ReceivesValue<Map<Method, Throwable>>> prepareExecution( final Object inst, Lifecycle lifecycle ) { return (callback) -> { final Map<Method, Throwable> result = newMap(); JUnitExecution controls = prepareToExecute(inst, lifecycle.tests); controls.setInstance(inst); setExecution(controls, inst); IntTo<Callable<Boolean>> tasks = X_Collect.newList(Callable.class); tasks.add(()->{ final Iterable<Callable<Boolean>> delays = controls.startClass(lifecycle.getTestClass(), inst); delays.forEach(delay->tasks.insert(0, delay)); return true; }); tasks.add(lifecycle.beforeClassInvoker(controls)); for (final Entry<String, Method> test : lifecycle.tests.entrySet()) { final Method method = test.getValue(); Test t = method.getAnnotation(Test.class); tasks.add( () -> { controls.normalizeLimits(); final Iterable<Callable<Boolean>> startDelays = controls.startMethod(method); final double timeout = t == null || t.timeout() == 0 ? getDefaultTimeout() : t.timeout(); final double deadline = X_Time.nowPlus(timeout); final UncaughtExceptionHandler oldHandler = GWT.getUncaughtExceptionHandler(); final JUnitExecution oldExecution = execution; // We insert the tasks backwards, from 0 tasks.insert( 0, () -> { try { if (controls.hasError()) { result.put(method, controls.getError()); } final Iterable<Callable<Boolean>> delays = controls.finishMethod(method, result.get(method)); delays.forEach(delay->tasks.insert(0, delay)); } finally { GWT.setUncaughtExceptionHandler(oldHandler); if (execution == controls) { execution = oldExecution; } } return true; } ); tasks.insert(0, lifecycle.afterInvoker(controls)); tasks.insert( 0, () -> { final Throwable error = execute(t, inst, method); result.put(method, error); if (controls.isTimeoutsClear()) { return true; } final Callable<Boolean>[] runner = new Callable[1]; runner[0] = () -> { if (controls.isFinished()) { return true; } if (X_Time.isPast(deadline)) { throw new TimeoutException("Test for " + method + " exceeded timeout of " + timeout + "ms."); } tasks.insert(0, runner[0]); return false; }; tasks.insert(0, runner[0]); return false; } ); tasks.insert(0, lifecycle.beforeInvoker(controls)); startDelays.forEach(delay->tasks.insert(0, delay)); // This one will be run first. It hijacks the uncaught exception handler, and sets the execution context tasks.insert( 0, () -> { GWT.setUncaughtExceptionHandler( e -> { controls.reportError(e, "Uncaught exception"); if (oldHandler != null) { oldHandler.onUncaughtException(e); } } ); execution = controls; return true; } ); return true; } ); } tasks.add(lifecycle.afterClassInvoker(controls)); tasks.add(()->{ int was = tasks.size(); final Iterable<Callable<Boolean>> delays = controls.finishClass(result); delays.forEach(delay->tasks.insert(0, delay)); return was != tasks.size(); }); RepeatingCommand command = () -> { while (!tasks.isEmpty()) { final Callable<Boolean> task = tasks.get(0); tasks.remove(0); boolean more; try { more = task.call(); } catch (Throwable e) { controls.reportError(e, "Unknown exception in " + task + " for " + inst); more = false; } if (!more) { return true; } } return false; }; while (command.execute()) ; if (controls.isFinished()) { // We got lucky. Nothing deferred occurred. finishExecution(controls, result, callback, inst, lifecycle.tests); } else { // There is a timer. Runnable[] wait = new Runnable[1]; wait[0] = () -> { int max = 10000; while (command.execute() && max-- > 0) ; assert max > 0 : "Infinite loop detected in junit execution of " + lifecycle + " on " + inst + "."; if (controls.isFinished()) { wait[0] = null; finishExecution(controls, result, callback, inst, lifecycle.tests); } else { X_Time.runLater(wait[0]); } }; X_Time.runLater(wait[0]); } }; } protected long getDefaultTimeout() { return 30000; } protected void finishExecution( JUnitExecution controls, Map<Method, Throwable> result, ReceivesValue<Map<Method, Throwable>> callback, Object inst, Map<String, Method> tests ) { callback.set(result); } protected JUnitExecution prepareToExecute(Object inst, Map<String, Method> tests) { final JUnitExecution oldExecution = execution; final JUnitExecution newExecution = execution = newExecution(); execution.onFinishedClass( e -> { if (execution == newExecution) { execution = oldExecution; setExecution(oldExecution, inst); } return null; } ); return execution; } protected JUnitExecution newExecution() { return new JUnitExecution(); } protected List<Method> newList(final Map<String, Method> beforeClass, final boolean reverse) { List<Method> list; if (reverse) { list = new LinkedList<Method>(); for (final Entry<String, Method> e : beforeClass.entrySet()) { list.add(0, e.getValue()); } } else { list = new ArrayList<Method>(); for (final Entry<String, Method> e : beforeClass.entrySet()) { list.add(e.getValue()); } } return list; } protected <K, V> Map<K, V> newMap() { return new LinkedHashMap<>(); } public void run(final Object inst, final Method m, ReceivesValue<Throwable> callback) throws Throwable { final Lifecycle lifecycle = newLifecycle(m.getDeclaringClass()) .withOnlyOneMethod(m); final ReceivesValue<ReceivesValue<Map<Method, Throwable>>> exe = prepareExecution(inst, lifecycle); final ReceivesValue<Map<Method, Throwable>> delegate = map -> callback.set(map.values().iterator().next()); scheduleExecution(exe, delegate); } private void scheduleExecution( ReceivesValue<ReceivesValue<Map<Method, Throwable>>> exe, ReceivesValue<Map<Method, Throwable>> delegate ) { exe.set(delegate); } private Lifecycle newLifecycle(Class<?> cls) { Lifecycle lifecycle = lifecycles.getOrCompute( cls, c -> initializeLifecycle(c, new Lifecycle(c)) ); return lifecycle; } protected Lifecycle initializeLifecycle(Class<?> cls, Lifecycle lifecycle) { return lifecycle; } public void runAll(final Class<?> testClass, Object inst, ReceivesValue<Map<Method, Throwable>> callback) { final Lifecycle lifecycle = newLifecycle(testClass); if (lifecycle.tests.size() > 0) { final ReceivesValue<ReceivesValue<Map<Method, Throwable>>> exe = prepareExecution(inst, lifecycle); exe.set(callback); } } protected Throwable execute(Test test, Object inst, Method value) { final Class<? extends Throwable> expected = test == null ? Test.None.class : test.expected(); // We'll have to figure out timeouts in the actual JUnit jvm try { try { value.invoke(inst); } catch (final InvocationTargetException e) { throw e.getCause(); } if (expected != Test.None.class) { return new AssertionError( "Method " + value + " was supposed to throw " + expected.getName() + ", but failed to do so" ); } return SUCCESS; } catch (final Throwable e) { // Allow user to `throw null;` if they want to "short circuit to success". // TODO make this only work as an opt in... if (e == null) { return SUCCESS; } if (!expected.isAssignableFrom(e.getClass())) { X_Debug.rethrow(e); } return expected.isAssignableFrom(e.getClass()) ? SUCCESS : e; } } protected void findAndSetField(Predicate<Class> matcher, Object value, Object inst, boolean forceSet) { try { Class<?> declaringClass = inst.getClass(); Field[] fields = GwtReflect.getPublicFields(declaringClass); for (Field field : fields) { if (matcher.test(field.getType())) { try { if (forceSet || field.get(inst) == null) { field.set(inst, value); } } catch (Throwable e) { X_Log.warn( getClass(), "findAndSetField for " + matcher + " on " + declaringClass + " (" + inst + ") encountered an error", e ); } } } while (declaringClass != null && declaringClass != Object.class) { fields = GwtReflect.getDeclaredFields(declaringClass); for (Field field : fields) { if (matcher.test(field.getType())) { try { field.setAccessible(true); if (forceSet || field.get(inst) == null) { field.set(inst, value); } } catch (Throwable e) { X_Log.warn(getClass(), "findAndSetField for "+matcher+" on "+declaringClass+" ("+inst+") encountered an error", e); } } } declaringClass = declaringClass.getSuperclass(); } } catch (Throwable e) { X_Log.warn(getClass(), "Unable to set value "+value+" on instance "+inst+" using matcher "+matcher); } } protected void setExecution(JUnitExecution execution, Object inst) { execution.autoClean(); findAndSetField(execution.getClass()::isAssignableFrom, execution, inst, true); } public static String debug(Object message, Throwable e) { final StringBuilder b = new StringBuilder(); b.append(message); b.append('\n'); b.append("<pre style='color:red;'>"); while (e != null) { b.append(e); b.append('\n'); for (final StackTraceElement trace : e.getStackTrace()) { b.append('\t') .append(trace.getClassName()) .append('.') .append(trace.getMethodName()) .append(' ') .append(trace.getFileName()) .append(':') .append(trace.getLineNumber()) .append('\n'); } e = e.getCause(); } b.append("</pre>"); return b.toString(); } /** * Synchronously execute the supplied method on the supplied object, rethrowing any exceptions we encounter. * * This method is deprecated and discouraged, since any test with asynchronicity must use a callback. */ @Deprecated public static void runTest(Object on, Method method) throws Throwable { Throwable[] result = new Throwable[0]; new JUnit4Executor().run(on, method, (s)->{ if (s != SUCCESS){ result[0] = s; } }); if (result[0] != null) { throw result[0]; } } public static void runTests(Class<?> cls) throws Exception { new JUnit4Executor().runAll( cls, cls.newInstance(), results -> { final Iterator<Entry<Method, Throwable>> itr = results.entrySet().iterator(); Throwable last = null; while (itr.hasNext()) { final Entry<Method,Throwable> next = itr.next(); if (next.getValue() == SUCCESS || next.getValue() == null) { itr.remove(); } else { last = next.getValue(); X_Log.error("Failure invoking "+next.getKey().getName(), last); } } if (!results.isEmpty()) { throw new RuntimeException(results.size()+ " tests in "+cls+" failed: "+results, last); } } ); } protected class Lifecycle { protected final Class<?> forClass; protected Map<String, Method> after = newMap(); protected Map<String, Method> afterClass = newMap(); protected Map<String, Method> before = newMap(); protected Map<String, Method> beforeClass = newMap(); protected Map<String, Method> tests = newMap(); public Lifecycle(Lifecycle from) { forClass = from.forClass; beforeClass.putAll(from.beforeClass); before.putAll(from.before); tests.putAll(from.tests); after.putAll(from.after); afterClass.putAll(from.afterClass); } @SuppressWarnings({ "rawtypes", "unchecked" }) public Lifecycle(final Class testClass) { Class initClass = forClass = testClass; while (initClass != null && initClass != Object.class) { try { for (final Method method : initClass.getMethods()) { if (method.getAnnotation(Test.class) != null) { assertPublicZeroArgInstanceMethod(method, Test.class); if (!tests.containsKey(method.getName())) { tests.put(method.getName(), method); } final Method previous = tests.put(method.getName(), method); if (previous != null) { kill(previous); } } addLifecycleMethods(method); } } catch (final NoSuchMethodError ignored) { debug("Class " + initClass + " is not enhanced", null); } catch (final Exception e) { debug("Error getting declared methods for " + initClass, e); } initClass = initClass.getSuperclass(); } } protected void kill(Method previous) { } public List<Method> after() { return newList(after, true); } public List<Method> afterClass() { return newList(afterClass, true); } public List<Method> before() { return newList(before, true); } public List<Method> beforeClass() { return newList(beforeClass, true); } private void addLifecycleMethods(final Method method) { if (method.getAnnotation(Before.class) != null) { assertPublicZeroArgInstanceMethod(method, Before.class); before.putIfAbsent(method.getName(), method); } if (method.getAnnotation(BeforeClass.class) != null) { assertPublicZeroArgStaticMethod(method, BeforeClass.class); beforeClass.putIfAbsent(method.getName(), method); } if (method.getAnnotation(After.class) != null) { assertPublicZeroArgInstanceMethod(method, After.class); after.putIfAbsent(method.getName(), method); } if (method.getAnnotation(AfterClass.class) != null) { assertPublicZeroArgStaticMethod(method, AfterClass.class); afterClass.putIfAbsent(method.getName(), method); } } public Lifecycle withOnlyOneMethod(Method m) { Lifecycle copy = new Lifecycle(this); copy.tests.clear(); copy.tests.put(m.getName(), m); return copy; } public Callable<Boolean> invoker(JUnitExecution controls, Collection<Method> list) { return () -> { for (final Method m : list) { m.invoke(controls.getInstance()); } return controls.isTimeoutsClear(); }; } public Callable<Boolean> beforeClassInvoker(JUnitExecution controls) { return invoker(controls, beforeClass()); } public Callable<Boolean> beforeInvoker(JUnitExecution controls) { return invoker(controls, before()); } public Callable<Boolean> afterClassInvoker(JUnitExecution controls) { return invoker(controls, afterClass()); } public Callable<Boolean> afterInvoker(JUnitExecution controls) { return invoker(controls, after()); } public Class<?> getTestClass() { return forClass; } } }