Java tutorial
// Copyright 2010, Mike Samuel // // 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 org.prebake.service.tools.ext; import org.prebake.fs.FilePerms; import org.prebake.js.JsonSink; import java.io.IOException; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.Writer; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.annotation.Nullable; import com.google.caja.lexer.escaping.Escaping; import com.google.common.base.Charsets; import com.google.common.base.Function; import com.google.common.base.Joiner; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMultimap; import com.google.common.collect.Lists; import com.google.common.collect.Maps; import com.google.common.collect.Sets; import com.google.common.io.Resources; /** * This class generates a tree of HTML files based on the results of running * JUnit tests using {@link JUnitRunner}. * See the unittests for the file tree structure. * * <h2>Directory Structure</h2> * The HTML structure is very simple -- each file loads a script, a stylesheet, * has a navigation bar of links to ancestor pages, a summary of test results * for the current page and pages linked to. * <p>Tests are grouped hierarchically: into packages, then into classes, * then by individual test method. Each grouping has an HTML file, so * index/{@code org.foo.myPackage.html} contains the summary for that package, * and the name without the extension is a directory, {@code org.foo,myPackage/} * which contains all the files named after the classes in that package. * <p>This convention of having a directory with the same name (minus extension) * as the summary HTML file is used many places below to simplify the logic, * especially around relative links, and is why there is a directory named * {@code index}. * * <h2>Integration Points -- Scripts and CSS</h2> * Each HTML page loads a CSS and JavaScript file, * <code>junit_report.{css,js}}</code> * from the same directory that contains the {@code index.html} file. * The CSS can hook into the HTML classes * (see {@link JunitHtmlReportGeneratorTest} for examples), and the JS file * should define a {@code startup} method that takes as input a string of * arrays describing the scope of the file * {@code ['index', '<package-name>', '<class-name>', '<test-name>']}, and a * array of JSON test data similar in structure to the {@code tests} member * of the original JSON report. * * @author Mike Samuel <mikesamuel@gmail.com> */ final class JunitHtmlReportGenerator { /** * Used to move package summary files out of the space of package names used * as directories. * Otherwise, packages that end in {@code .html} collide with the package * summary for their parent package. */ private static final String PACKAGE_FILE_SUFFIX = "-pkg"; static void generateHtmlReport(Map<String, ?> jsonReport, Path reportDir) throws IOException { // Group tests by packages so we can let users examine the results by // logical groupings. ImmutableMultimap<String, Map<?, ?>> byPackage; List<String> resultTypes; { Set<String> resultTypeSet = Sets.newHashSet(); ImmutableList.Builder<Map<?, ?>> b = ImmutableList.builder(); // We can't trust the jsonReport to have the same structure as the method // above, since a filter could arbitrarily change it. Object tests = jsonReport.get(ReportKey.TESTS); if (tests instanceof Iterable<?>) { for (Object testVal : (Iterable<?>) tests) { if (!(testVal instanceof Map<?, ?>)) { continue; // If filter nulls out elements. } Map<?, ?> test = (Map<?, ?>) testVal; b.add(test); String result = getIfOfType(test, ReportKey.RESULT, String.class); resultTypeSet.add(result); } } byPackage = groupBy(b.build(), new Function<Map<?, ?>, String>() { public String apply(Map<?, ?> test) { String className = getIfOfType(test, ReportKey.CLASS_NAME, String.class); if (className == null) { return null; } int lastDot = className.lastIndexOf('.'); return (lastDot >= 0) ? className.substring(0, lastDot) : ""; } }); String[] resultTypeArr = resultTypeSet.toArray(NO_STRINGS); Arrays.sort(resultTypeArr); resultTypes = ImmutableList.copyOf(resultTypeArr); } Map<String, Integer> summary = Maps.newHashMap(); ImmutableList.Builder<Html> table = ImmutableList.builder(); // Now, call out to create the package groupings, which in turn, // create the class level groupings, which in turn create pages for // individual tests. // As we descend into the test tree, each recursive call updates summary // info. Path outFile = reportDir.resolve("index.html"); mkdirs(outFile.getParent()); String[] packageNames = byPackage.keySet().toArray(NO_STRINGS); Arrays.sort(packageNames); for (String packageName : packageNames) { Collection<Map<?, ?>> tests = byPackage.get(packageName); Map<String, Integer> itemSummary = generateHtmlReportOnePackage(packageName, tests, reportDir.resolve("index"), resultTypes); bagPutAll(itemSummary, summary); table.add(htmlLink("index/" + packageName + PACKAGE_FILE_SUFFIX + ".html", packageName)) .add(htmlSpan("summary", summaryToHtml(itemSummary, resultTypes))); } writeReport(outFile, "JUnit", KEY_VAL, table.build(), summary, jsonReport, resultTypes, "index"); OutputStream out = reportDir.resolve("junit_report.css").newOutputStream(StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); try { Resources.copy(Resources.getResource(JunitHtmlReportGenerator.class, "junit_report.css"), out); } finally { out.close(); } } private static Map<String, Integer> generateHtmlReportOnePackage(String packageName, Collection<Map<?, ?>> tests, Path reportDir, List<String> resultTypes) throws IOException { ImmutableMultimap<String, Map<?, ?>> byClass = groupBy(tests, new Function<Map<?, ?>, String>() { public String apply(Map<?, ?> test) { String className = getIfOfType(test, ReportKey.CLASS_NAME, String.class); int lastDot = className.lastIndexOf('.'); return lastDot >= 0 ? className.substring(lastDot + 1) : className; } }); Map<String, Integer> summary = Maps.newHashMap(); ImmutableList.Builder<Html> table = ImmutableList.builder(); Path outFile = reportDir.resolve(packageName + PACKAGE_FILE_SUFFIX + ".html"); mkdirs(outFile.getParent()); String[] classNames = byClass.keySet().toArray(NO_STRINGS); Arrays.sort(classNames); for (String className : classNames) { Collection<Map<?, ?>> classTests = byClass.get(className); Map<String, Integer> itemSummary = generateHtmlReportOneClass(packageName, className, classTests, reportDir.resolve(packageName), resultTypes); bagPutAll(itemSummary, summary); table.add(htmlLink(packageName + "/" + className + ".html", className)) .add(htmlSpan("summary", summaryToHtml(itemSummary, resultTypes))); } writeReport(outFile, "package " + packageName, KEY_VAL, table.build(), summary, tests, resultTypes, "index", packageName + PACKAGE_FILE_SUFFIX); return summary; } private static Map<String, Integer> generateHtmlReportOneClass(String packageName, String className, Collection<Map<?, ?>> tests, Path reportDir, List<String> resultTypes) throws IOException { ImmutableMultimap<String, Map<?, ?>> byTestName = groupBy(tests, new Function<Map<?, ?>, String>() { int counter = 0; public String apply(Map<?, ?> test) { String methodName = getIfOfType(test, ReportKey.METHOD_NAME, String.class); if (methodName != null) { return methodName; } String testName = getIfOfType(test, ReportKey.TEST_NAME, String.class); if (testName != null) { return testName; } return "#" + (counter++); } }); ImmutableList.Builder<Html> table = ImmutableList.builder(); Map<String, Integer> summary = Maps.newHashMap(); Path outFile = reportDir.resolve(className + ".html"); mkdirs(outFile.getParent()); String[] testNames = byTestName.keySet().toArray(NO_STRINGS); Arrays.sort(testNames); for (String testName : testNames) { int counter = 0; for (Map<?, ?> test : byTestName.get(testName)) { int testIndex = counter++; Map<String, Integer> itemSummary = generateHtmlReportOneTest(packageName, className, testName, testIndex, test, reportDir.resolve(className), resultTypes); bagPutAll(itemSummary, summary); table.add(htmlLink(className + "/" + testName + "_" + testIndex + ".html", testName)) .add(htmlSpan("summary", summaryToHtml(itemSummary, resultTypes))); Object cause = test.get(ReportKey.FAILURE_MESSAGE); table.add(htmlFromString(cause instanceof String ? (String) cause : "")); } } writeReport(outFile, "class " + className, KEY_VAL_PREVIEW, table.build(), summary, tests, resultTypes, "index", packageName + PACKAGE_FILE_SUFFIX, className); return summary; } private static Map<String, Integer> generateHtmlReportOneTest(String packageName, String className, String testName, int testIndex, Map<?, ?> test, Path reportDir, List<String> resultTypes) throws IOException { String testId = testName + "_" + testIndex; String result = getIfOfType(test, ReportKey.RESULT, String.class); if (result == null) { result = "unknown"; } Map<String, Integer> summary = Collections.singletonMap(result, 1); ImmutableList.Builder<Html> table = ImmutableList.builder(); String displayName; { displayName = getIfOfType(test, ReportKey.TEST_NAME, String.class); if (displayName != null && !"".equals(displayName)) { table.add(htmlFromString("Name")).add(htmlFromString(displayName)); } else { displayName = testName; } } { String failureMsg = getIfOfType(test, ReportKey.FAILURE_MESSAGE, String.class); if (failureMsg != null && !"".equals(failureMsg)) { table.add(htmlFromString("Cause")).add(htmlFromString(failureMsg)); } } { String failureTrace = getIfOfType(test, ReportKey.FAILURE_TRACE, String.class); if (failureTrace != null && !"".equals(failureTrace)) { table.add(htmlFromString("Trace")).add(htmlFromTrace(failureTrace)); } } { String output = getIfOfType(test, ReportKey.OUT, String.class); if (output != null && !"".equals(output)) { table.add(htmlFromString("Output")).add(htmlFromString(output)); } } Path outFile = reportDir.resolve(testId + ".html"); mkdirs(outFile.getParent()); writeReport(outFile, "test " + displayName, KEY_VAL, table.build(), summary, // Wrap test in a list for consistency. ImmutableList.of(test), resultTypes, "index", packageName + PACKAGE_FILE_SUFFIX, className, testId); return summary; } private static String nParent(int n) { StringBuilder sb = new StringBuilder(); for (int i = n; --i >= 0;) { sb.append("../"); } return sb.toString(); } private static final ImmutableList<String> KEY_VAL = ImmutableList.of("key", "value"); private static final ImmutableList<String> KEY_VAL_PREVIEW = ImmutableList.<String>builder().addAll(KEY_VAL) .add("preview").build(); private static void writeReport(Path outFile, String title, List<String> columns, List<Html> table, Map<String, Integer> summary, Object json, List<String> resultTypes, String... navBar) throws IOException { int depth = navBar.length; String baseDir = nParent(depth - 1); mkdirs(outFile.getParent()); Writer out = new OutputStreamWriter( outFile.newOutputStream(StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING), Charsets.UTF_8); try { // First, write out some style and script links so that clients can // customize the content. out.append("<html><head><title>"); appendHtml(out, title); out.append("</title><link rel=\"stylesheet\" type=\"text/css\" href=\"").append(baseDir) .append("junit_report.css\" /><script src=\"").append(baseDir) .append("junit_report.js\"></script>"); if (json != null) { out.append("<script type=\"text/javascript\">startup(") .append(JsonSink.stringify(Arrays.asList(navBar)).replaceAll("</", "<\\/")).append(", ") .append(JsonSink.stringify(json)).append(");</script>"); } out.append("</head><body class=\"tree_level_").append(Integer.toString(depth)).append("\">"); // The title/navigation bar out.append("<h1>"); for (int i = 0; i < depth - 1; ++i) { out.append("<a class=\"nav_anc\" href=\"").append(nParent(depth - i - 1)).append(navBar[i]) .append(".html\">"); appendHtml(out, navBarText(navBar[i])); out.append("</a><span class=\"nav_sep\">|</span>"); } out.append("<span class=\"nav_top\">"); appendHtml(out, navBarText(navBar[depth - 1])); out.append("</span></h1>"); out.append("<span class=\"page_summary\">"); // The summary summaryToHtml(summary, resultTypes).appendTo(out); out.append("</span>"); out.append("<table class=\"data_table\">"); for (int i = 0, r = 0, n = table.size(); i < n; ++r) { Html name = table.get(i); out.append("<tr class=\"data_row "); out.append((r & 1) == 0 ? "even " : "odd "); String plainName = name.asPlainText(); if (plainName.indexOf('.') < 0 // not valid in class names && plainName.indexOf(' ') < 0) { appendHtml(out, plainName); } out.append("\">"); for (String column : columns) { out.append("<td class=\""); appendHtml(out, column); out.append("\">"); table.get(i++).appendTo(out); out.append("</td>"); } } out.append("</table></body></html>"); } finally { out.close(); } } private static String navBarText(String navBarShort) { int dash = navBarShort.lastIndexOf('-'); // Strip PACKAGE_FILE_SUFFIX from the end of the nav bar text. if (dash >= 0) { navBarShort = navBarShort.substring(0, dash); } return navBarShort; } static Html summaryToHtml(Map<String, Integer> summary, List<String> resultTypes) { int total = 0; ImmutableList.Builder<Html> parts = ImmutableList.builder(); Html sep = htmlSpan("summary_sep", ","); Html nonzeroSep = htmlSpan("summary_sep nonzero", ","); boolean first = true, sawNonzero = false; for (String summaryKey : resultTypes) { Integer n = summary.get(summaryKey); int count = n != null ? n : 0; if (!first) { if (count != 0) { parts.add(sawNonzero ? nonzeroSep : sep); sawNonzero = true; } else { parts.add(sep); } } else { first = false; sawNonzero = count != 0; } parts.add(summaryPairToHtml(summaryKey, count)); total += count; } if (!first) { parts.add(sawNonzero ? nonzeroSep : sep); } parts.add(summaryPairToHtml(ReportKey.TOTAL, total)); return htmlConcat(parts.build()); } private static Html summaryPairToHtml(final String summaryKey, final int n) { return new Html() { public void appendTo(Appendable out) throws IOException { out.append("<span class=\"summary_pair "); if (n != 0) { out.append("nonzero "); } appendHtml(out, summaryKey); out.append("\"><span class=\"summary_key\">"); appendHtml(out, summaryKey); out.append("</span><span class=\"summary_spacer\">:</span>") .append("<span class=\"summary_value\">").append(Integer.toString(n)) .append("</span></span>"); } public String asPlainText() { return summaryKey + ":" + n; } }; } static void appendHtml(Appendable out, String plainText) throws IOException { Escaping.escapeXml(plainText, false, out); } private static <T> void bagPutAll(Map<T, Integer> sourceBag, Map<T, Integer> destBag) { for (Map.Entry<T, Integer> summaryEntry : sourceBag.entrySet()) { T k = summaryEntry.getKey(); int delta = summaryEntry.getValue(); Integer oldCount = destBag.get(k); int newCount = delta + (oldCount != null ? oldCount : 0); if (newCount != 0) { destBag.put(k, newCount); } else { destBag.remove(k); } } } private static ImmutableMultimap<String, Map<?, ?>> groupBy(Iterable<Map<?, ?>> tests, Function<Map<?, ?>, String> keyFn) { ImmutableMultimap.Builder<String, Map<?, ?>> b = ImmutableMultimap.builder(); for (Map<?, ?> test : tests) { String name = keyFn.apply(test); if (name != null) { b.put(name, test); } } return b.build(); } private static @Nullable <T> T getIfOfType(Map<?, ?> map, Object key, Class<T> type) { Object value = map.get(key); if (value != null && type.isInstance(value)) { return type.cast(value); } return null; } static interface Html { void appendTo(Appendable out) throws IOException; String asPlainText(); } static Html htmlFromString(final String plainText) { return new Html() { public void appendTo(Appendable out) throws IOException { appendHtml(out, plainText); } public String asPlainText() { return plainText; } }; } // TODO: could this logic move out of java and CSS if I just styled lines like // "\tat (classname)" with classes for each package prefix and the full class // name, and added CSS like // .stack_trace.org_junit_Asserts { color: #888 } // ? private static final Pattern STACK_TRACE_FILTER_SUFFIX = Pattern .compile("" + "^\tat (?:" + "org\\.prebake\\.service\\.tools\\.ext\\.JUnitRunner" + "|org\\.junit\\." + "|junit\\." + "|java\\.lang\\.reflect\\." + "|sun\\.reflect\\.)"); private static final Pattern STACK_TRACE_FILTER_PREFIX = Pattern .compile("^\tat (?:org\\.junit\\.Assert|junit\\.framework\\.Assert)\\b"); private static final Pattern GOLDEN_VS_ACTUAL = Pattern.compile("^(.* expected:<)(.*?)(> but was:<)(.*)(>)$", Pattern.DOTALL); /** * Split the stack trace into filtered portions and unfiltered portions. */ static Html htmlFromTrace(final String stackTrace) { String[] lines = stackTrace.split("(?:\r\n?|\n)(?=\tat )"); int n = lines.length; int f = 0; while (f < n && !lines[f].startsWith("\tat ")) { ++f; } int s = f; int e = n; while (s < n && STACK_TRACE_FILTER_PREFIX.matcher(lines[s]).find()) { ++s; } while (e > s && STACK_TRACE_FILTER_SUFFIX.matcher(lines[e - 1]).find()) { --e; } List<Html> parts = Lists.newArrayList(); if (f > 0) { String th = Joiner.on('\n').join(Arrays.asList(lines).subList(0, f)); Matcher m = GOLDEN_VS_ACTUAL.matcher(th); if (m.matches()) { List<Html> thParts = Lists.newArrayList(); thParts.add(htmlFromString(m.group(1))); thParts.add(htmlSpan("golden", m.group(2))); thParts.add(htmlFromString(m.group(3))); thParts.add(htmlSpan("actual", m.group(4))); thParts.add(htmlFromString(m.group(5))); parts.add(htmlSpan("throwable", htmlConcat(thParts))); } else { parts.add(htmlSpan("throwable", th)); } parts.add(htmlFromString("\n")); } if (s > f) { parts.add(htmlSpan("filtered", Joiner.on('\n').join(Arrays.asList(lines).subList(f, s)))); parts.add(htmlFromString("\n")); } if (s < e) { parts.add(htmlSpan("unfiltered", Joiner.on('\n').join(Arrays.asList(lines).subList(s, e)))); parts.add(htmlFromString("\n")); } if (e < n) { parts.add(htmlSpan("filtered", Joiner.on('\n').join(Arrays.asList(lines).subList(e, n)))); parts.add(htmlFromString("\n")); } return htmlConcat(parts); } static Html htmlLink(String href, String body) { return htmlLink(href, htmlFromString("".equals(body) ? "\u00A0" : body)); } static Html htmlLink(final String href, final Html body) { return new Html() { public void appendTo(Appendable out) throws IOException { out.append("<a href=\""); appendHtml(out, href); out.append("\">"); body.appendTo(out); out.append("</a>"); } public String asPlainText() { return body.asPlainText(); } }; } static Html htmlSpan(String classes, String body) { return htmlSpan(classes, htmlFromString("".equals(body) ? "\u00A0" : body)); } static Html htmlSpan(final String classes, final Html body) { return new Html() { public void appendTo(Appendable out) throws IOException { out.append("<span class=\""); appendHtml(out, classes); out.append("\">"); body.appendTo(out); out.append("</span>"); } public String asPlainText() { return body.asPlainText(); } }; } static Html htmlConcat(Collection<Html> html) { if (html.size() == 1) { return html.iterator().next(); } final ImmutableList<Html> parts = ImmutableList.copyOf(html); return new Html() { public void appendTo(Appendable out) throws IOException { for (Html html : parts) { html.appendTo(out); } } public String asPlainText() { StringBuilder sb = new StringBuilder(); for (Html html : parts) { sb.append(html.asPlainText()); } return sb.toString(); } }; } private static final String[] NO_STRINGS = new String[0]; private static void mkdirs(Path d) throws IOException { if (d.notExists()) { Path p = d.getParent(); if (p != null) { mkdirs(p); } d.createDirectory(FilePerms.perms(0700, true)); } } }