From 72cf99b25c43c25d923ee5e2f9a37cc036f32a0b Mon Sep 17 00:00:00 2001 From: Edgars Batna Date: Wed, 24 Jul 2024 16:23:06 +0200 Subject: [PATCH] Test history refactoring and improvements (#625) Co-authored-by: Tim Jacomb <21194782+timja@users.noreply.github.com> Co-authored-by: Edgars Batna --- pom.xml | 16 + .../java/hudson/tasks/junit/CaseResult.java | 191 ++++- .../java/hudson/tasks/junit/ClassResult.java | 9 +- src/main/java/hudson/tasks/junit/History.java | 670 ++++++++++++++++-- .../tasks/junit/HistoryTestResultSummary.java | 24 +- .../java/hudson/tasks/junit/JUnitParser.java | 31 +- .../tasks/junit/JUnitResultArchiver.java | 23 +- .../java/hudson/tasks/junit/JUnitTask.java | 2 + .../java/hudson/tasks/junit/SuiteResult.java | 203 +++++- .../java/hudson/tasks/junit/TestResult.java | 152 +++- .../hudson/tasks/junit/TestResultAction.java | 84 ++- .../junit/pipeline/JUnitResultsStep.java | 20 + .../test/TestDurationTrendSeriesBuilder.java | 3 + .../hudson/tasks/test/TestObjectIterable.java | 31 +- .../tasks/test/TestResultProjectAction.java | 11 +- .../tasks/test/TestResultTrendChart.java | 3 +- .../hudson/tasks/junit/CaseResult/index.jelly | 4 +- .../hudson/tasks/junit/History/history.js | 409 +++++++++++ .../hudson/tasks/junit/History/index.jelly | 165 +++-- .../junit/JUnitResultArchiver/config.jelly | 3 + .../TestResultProjectAction/floatingBox.jelly | 1 + src/main/webapp/history/history.css | 37 +- src/main/webapp/history/history.js | 69 -- .../hudson/tasks/junit/CaseResultTest.java | 3 +- .../hudson/tasks/junit/SuiteResult2Test.java | 2 +- .../hudson/tasks/junit/SuiteResultTest.java | 8 +- .../hudson/tasks/junit/TestResultTest.java | 59 +- .../junit/pipeline/JUnitResultsStepTest.java | 35 +- .../storage/TestResultStorageJunitTest.java | 2 +- .../tasks/junit/junit-report-bad-duration.xml | 7 + .../hudson/tasks/junit/junit-report-huge.xml | 244 +++++++ 31 files changed, 2220 insertions(+), 301 deletions(-) create mode 100644 src/main/resources/hudson/tasks/junit/History/history.js delete mode 100644 src/main/webapp/history/history.js create mode 100644 src/test/resources/hudson/tasks/junit/junit-report-bad-duration.xml create mode 100644 src/test/resources/hudson/tasks/junit/junit-report-huge.xml diff --git a/pom.xml b/pom.xml index de0ba1893..0db9fe0d0 100644 --- a/pom.xml +++ b/pom.xml @@ -181,11 +181,27 @@ org.jenkins-ci.plugins jackson2-api + + com.pivovarit + parallel-collectors + 2.6.1 + org.jenkins-ci.plugins pipeline-utility-steps test + + ca.umontreal.iro.simul + ssj + 3.3.1 + + + com.google.code.gson + gson + + + diff --git a/src/main/java/hudson/tasks/junit/CaseResult.java b/src/main/java/hudson/tasks/junit/CaseResult.java index 309a63d1b..9c4e8fa27 100644 --- a/src/main/java/hudson/tasks/junit/CaseResult.java +++ b/src/main/java/hudson/tasks/junit/CaseResult.java @@ -51,11 +51,15 @@ import java.util.Collections; import java.util.Comparator; import java.util.List; +import java.util.logging.Level; import java.util.logging.Logger; import static java.util.Collections.emptyList; import static java.util.Collections.singletonList; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamReader; + /** * One test result. * @@ -65,7 +69,7 @@ */ public class CaseResult extends TestResult implements Comparable { private static final Logger LOGGER = Logger.getLogger(CaseResult.class.getName()); - private final float duration; + private float duration; /** * Start time in epoch milliseconds - default is -1 for unset */ @@ -74,17 +78,18 @@ public class CaseResult extends TestResult implements Comparable { * In JUnit, a test is a method of a class. This field holds the fully qualified class name * that the test was in. */ - private final String className; + private String className; /** * This field retains the method name. */ - private final String testName; + private String testName; private transient String safeName; - private final boolean skipped; - private final String skippedMessage; - private final String errorStackTrace; - private final String errorDetails; - private final Map properties; + private boolean skipped; + private boolean keepTestNames; + private String skippedMessage; + private String errorStackTrace; + private String errorDetails; + private Map properties; @SuppressFBWarnings(value = "SE_TRANSIENT_FIELD_NOT_RESTORED", justification = "Specific method to restore it") private transient SuiteResult parent; @@ -98,14 +103,14 @@ public class CaseResult extends TestResult implements Comparable { * If these information are reported at the test case level, these fields are set, * otherwise null, in which case {@link SuiteResult#stdout}. */ - private final String stdout,stderr; + private String stdout,stderr; /** * This test has been failing since this build number (not id.) * * If {@link #isPassed() passing}, this field is left unused to 0. */ - private /*final*/ int failedSince; + private int failedSince; private static float parseTime(Element testCase) { String time = testCase.attributeValue("time"); @@ -138,6 +143,7 @@ public CaseResult(SuiteResult parent, String testName, String errorStackTrace, S this.skipped = false; this.skippedMessage = null; this.properties = Collections.emptyMap(); + this.keepTestNames = false; } @Restricted(Beta.class) @@ -165,14 +171,15 @@ public CaseResult( this.skipped = skippedMessage != null; this.skippedMessage = skippedMessage; this.properties = Collections.emptyMap(); + this.keepTestNames = false; } @Deprecated - CaseResult(SuiteResult parent, Element testCase, String testClassName, boolean keepLongStdio, boolean keepProperties) { - this(parent, testCase, testClassName, StdioRetention.fromKeepLongStdio(keepLongStdio), keepProperties); + CaseResult(SuiteResult parent, Element testCase, String testClassName, boolean keepLongStdio, boolean keepProperties, boolean keepTestNames) { + this(parent, testCase, testClassName, StdioRetention.fromKeepLongStdio(keepLongStdio), keepProperties, keepTestNames); } - CaseResult(SuiteResult parent, Element testCase, String testClassName, StdioRetention stdioRetention, boolean keepProperties) { + CaseResult(SuiteResult parent, Element testCase, String testClassName, StdioRetention stdioRetention, boolean keepProperties, boolean keepTestNames) { // schema for JUnit report XML format is not available in Ant, // so I don't know for sure what means what. // reports in http://www.nabble.com/difference-in-junit-publisher-and-ant-junitreport-tf4308604.html#a12265700 @@ -200,7 +207,7 @@ public CaseResult( errorStackTrace = getError(testCase); errorDetails = getErrorMessage(testCase); this.parent = parent; - duration = parseTime(testCase); + duration = clampDuration(parseTime(testCase)); this.startTime = -1; skipped = isMarkedAsSkipped(testCase); skippedMessage = getSkippedMessage(testCase); @@ -226,6 +233,138 @@ public CaseResult( } } this.properties = properties; + this.keepTestNames = keepTestNames; + } + + public CaseResult(CaseResult src) { + this.duration = src.duration; + this.className = src.className; + this.testName = src.testName; + this.skippedMessage = src.skippedMessage; + this.skipped = src.skipped; + this.keepTestNames = src.keepTestNames; + this.errorStackTrace = src.errorStackTrace; + this.errorDetails = src.errorDetails; + this.failedSince = src.failedSince; + this.stdout = src.stdout; + this.stderr = src.stderr; + this.properties = new HashMap<>(); + this.properties.putAll(src.properties); + } + + public static float clampDuration(float d) { + return Math.min(365.0f * 24 * 60 * 60, Math.max(0.0f, d)); + } + + public static CaseResult parse(SuiteResult parent, final XMLStreamReader reader, String ver) throws XMLStreamException { + CaseResult r = new CaseResult(parent, null, null, null); + while (reader.hasNext()) { + final int event = reader.next(); + if (event == XMLStreamReader.END_ELEMENT && reader.getLocalName().equals("case")) { + return r; + } + if (event == XMLStreamReader.START_ELEMENT) { + final String elementName = reader.getLocalName(); + switch (elementName) { + case "duration": + r.duration = clampDuration(new TimeToFloat(reader.getElementText()).parse()); + break; + case "startTime": + r.startTime = Long.parseLong(reader.getElementText()); + break; + case "className": + r.className = reader.getElementText(); + break; + case "testName": + r.testName = reader.getElementText(); + break; + case "skippedMessage": + r.skippedMessage = reader.getElementText(); + break; + case "skipped": + r.skipped = Boolean.parseBoolean(reader.getElementText()); + break; + case "keepTestNames": + r.keepTestNames = Boolean.parseBoolean(reader.getElementText()); + break; + case "errorStackTrace": + r.errorStackTrace = reader.getElementText(); + break; + case "errorDetails": + r.errorDetails = reader.getElementText(); + break; + case "failedSince": + r.failedSince = Integer.parseInt(reader.getElementText()); + break; + case "stdout": + r.stdout = reader.getElementText(); + break; + case "stderr": + r.stderr = reader.getElementText(); + break; + case "properties": + r.properties = new HashMap<>(); + parseProperties(r.properties, reader, ver); + break; + default: + if (LOGGER.isLoggable(Level.FINEST)) { + LOGGER.finest("CaseResult.parse encountered an unknown field: " + elementName); + } + } + } + } + return r; + } + + public static void parseProperties(Map r, final XMLStreamReader reader, String ver) throws XMLStreamException { + while (reader.hasNext()) { + final int event = reader.next(); + if (event == XMLStreamReader.END_ELEMENT && reader.getLocalName().equals("properties")) { + return; + } + if (event == XMLStreamReader.START_ELEMENT) { + final String elementName = reader.getLocalName(); + switch (elementName) { + case "entry": + parseProperty(r, reader, ver); + break; + default: + if (LOGGER.isLoggable(Level.FINEST)) { + LOGGER.finest("CaseResult.parseProperties encountered an unknown field: " + elementName); + } + } + } + } + } + + public static void parseProperty(Map r, final XMLStreamReader reader, String ver) throws XMLStreamException { + String name = null, value = null; + while (reader.hasNext()) { + final int event = reader.next(); + if (event == XMLStreamReader.END_ELEMENT && reader.getLocalName().equals("entry")) { + if (name != null && value != null) { + r.put(name, value); + } + return; + } + if (event == XMLStreamReader.START_ELEMENT) { + final String elementName = reader.getLocalName(); + switch (elementName) { + case "string": + if (name == null) { + name = reader.getElementText(); + } else { + value = reader.getElementText(); + } + break; + default: + if (LOGGER.isLoggable(Level.FINEST)) { + LOGGER.finest("CaseResult.parseProperty encountered an unknown field: " + elementName); + } + } + } + } + } static String possiblyTrimStdio(Collection results, StdioRetention stdioRetention, String stdio) { // HUDSON-6516 @@ -354,7 +493,7 @@ public String getDisplayName() { private String getNameWithEnclosingBlocks(String rawName) { // Only prepend the enclosing flow node names if there are any and the run this is in has multiple blocks directly containing // test results. - if (!getEnclosingFlowNodeNames().isEmpty()) { + if (!keepTestNames && !getEnclosingFlowNodeNames().isEmpty()) { Run r = getRun(); if (r != null) { TestResultAction action = r.getAction(TestResultAction.class); @@ -579,15 +718,27 @@ public String getStderr() { return getSuiteResult().getStderr(); } + static int PREVIOUS_TEST_RESULT_BACKTRACK_BUILDS_MAX = + Integer.parseInt(System.getProperty(History.HistoryTableResult.class.getName() + ".PREVIOUS_TEST_RESULT_BACKTRACK_BUILDS_MAX","25")); + @Override public CaseResult getPreviousResult() { if (parent == null) return null; - TestResult previousResult = parent.getParent().getPreviousResult(); - if (previousResult == null) return null; - if (previousResult instanceof hudson.tasks.junit.TestResult) { - hudson.tasks.junit.TestResult pr = (hudson.tasks.junit.TestResult) previousResult; - return pr.getCase(parent.getName(), getTransformedFullDisplayName()); + TestResult previousResult = parent.getParent(); + int n = 0; + while (previousResult != null && n < PREVIOUS_TEST_RESULT_BACKTRACK_BUILDS_MAX) { + previousResult = previousResult.getPreviousResult(); + if (previousResult == null) + return null; + if (previousResult instanceof hudson.tasks.junit.TestResult) { + hudson.tasks.junit.TestResult pr = (hudson.tasks.junit.TestResult) previousResult; + CaseResult cr = pr.getCase(parent.getName(), getTransformedFullDisplayName()); + if (cr != null) { + return cr; + } + } + ++n; } return null; } diff --git a/src/main/java/hudson/tasks/junit/ClassResult.java b/src/main/java/hudson/tasks/junit/ClassResult.java index c0fc2ae5d..387213701 100644 --- a/src/main/java/hudson/tasks/junit/ClassResult.java +++ b/src/main/java/hudson/tasks/junit/ClassResult.java @@ -31,9 +31,7 @@ import org.kohsuke.stapler.StaplerResponse; import org.kohsuke.stapler.export.Exported; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; +import java.util.*; /** * Cumulative test result of a test class. @@ -44,7 +42,7 @@ public final class ClassResult extends TabulatedResult implements Comparable cases = new ArrayList<>(); + private final Set cases = new TreeSet(); private int passCount,failCount,skipCount; @@ -146,7 +144,7 @@ public Object getDynamic(String name, StaplerRequest req, StaplerResponse rsp) { @Exported(name="child") @Override - public List getChildren() { + public Collection getChildren() { return cases; } @@ -216,7 +214,6 @@ else if(r.isPassed()) { void freeze() { this.tally(); - Collections.sort(cases); } public String getClassName() { diff --git a/src/main/java/hudson/tasks/junit/History.java b/src/main/java/hudson/tasks/junit/History.java index f5b7c070a..1d11aa3ca 100644 --- a/src/main/java/hudson/tasks/junit/History.java +++ b/src/main/java/hudson/tasks/junit/History.java @@ -23,25 +23,40 @@ */ package hudson.tasks.junit; -import java.util.List; -import java.util.Objects; +import java.lang.ref.SoftReference;import java.math.RoundingMode; +import java.text.DecimalFormat;import java.util.*; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Logger; +import java.util.logging.Level; import java.util.stream.Collectors; -import edu.hm.hafner.echarts.ChartModelConfiguration; -import edu.hm.hafner.echarts.JacksonFacade; -import edu.hm.hafner.echarts.LinesChartModel; - +import jenkins.util.SystemProperties; import org.kohsuke.accmod.Restricted; import org.kohsuke.accmod.restrictions.NoExternalUse; import org.kohsuke.stapler.bind.JavaScriptMethod; + +import com.pivovarit.collectors.ParallelCollectors; + +import edu.hm.hafner.echarts.ChartModelConfiguration; +import edu.hm.hafner.echarts.JacksonFacade; +import edu.hm.hafner.echarts.LinesChartModel; +import hudson.model.Run; import hudson.tasks.test.TestObject; import hudson.tasks.test.TestObjectIterable; -import hudson.tasks.test.TestResultDurationChart; import hudson.tasks.test.TestResultTrendChart; import hudson.util.RunList; - import io.jenkins.plugins.junit.storage.TestResultImpl; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import umontreal.ssj.functionfit.LeastSquares; +import umontreal.ssj.functionfit.SmoothingCubicSpline; + /** * History of {@link hudson.tasks.test.TestObject} over time. * @@ -49,6 +64,7 @@ */ @Restricted(NoExternalUse.class) public class History { + private static final Logger LOGGER = Logger.getLogger(History.class.getName()); private static final JacksonFacade JACKSON_FACADE = new JacksonFacade(); private static final String EMPTY_CONFIGURATION = "{}"; private final TestObject testObject; @@ -64,49 +80,506 @@ public TestObject getTestObject() { @SuppressWarnings("unused") // Called by jelly view public boolean historyAvailable() { - if (testObject instanceof hudson.tasks.junit.TestResult) { - TestResultImpl pluggableStorage = ((hudson.tasks.junit.TestResult) testObject).getPluggableStorage(); - if (pluggableStorage != null) { - return pluggableStorage.getCountOfBuildsWithTestResults() > 1; - } - } - - return testObject.getRun().getParent().getBuilds().size() > 1; + return true; } @JavaScriptMethod @SuppressWarnings("unused") // Called by jelly view - public String getTestResultTrend(String configuration) { - return JACKSON_FACADE.toJson(createTestResultTrend(ChartModelConfiguration.fromJson(configuration))); + public String getTestResultTrend(int start, int end, String configuration) { + return JACKSON_FACADE.toJson(createTestResultTrend(start, end, ChartModelConfiguration.fromJson(configuration))); } - private LinesChartModel createTestResultTrend(ChartModelConfiguration chartModelConfiguration) { + private LinesChartModel createTestResultTrend(int start, int end, ChartModelConfiguration chartModelConfiguration) { TestResultImpl pluggableStorage = getPluggableStorage(); if (pluggableStorage != null) { return new TestResultTrendChart().create(pluggableStorage.getTrendTestResultSummary()); } + return new TestResultTrendChart().createFromTestObject(createBuildHistory(testObject, start, end), chartModelConfiguration); + } + + private ObjectNode computeDurationTrendJson(List history) { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode root = mapper.createObjectNode(); + ArrayNode domainAxisLabels = mapper.createArrayNode(); + ArrayNode series = mapper.createArrayNode(); + ObjectNode durationSeries = mapper.createObjectNode(); + series.add(durationSeries); + durationSeries.put("type", "line"); + durationSeries.put("symbol", "circle"); + durationSeries.put("symbolSize", "6"); + durationSeries.put("sampling", "lttb"); + ArrayNode durationData = mapper.createArrayNode(); + durationSeries.set("data", durationData); + ObjectNode durationStyle = mapper.createObjectNode(); + durationSeries.set("itemStyle", durationStyle); + durationStyle.put("color", "rgba(160, 173, 177, 0.6)"); + ObjectNode durationAreaStyle = mapper.createObjectNode(); + durationSeries.set("areaStyle", durationAreaStyle); + durationAreaStyle.put("normal", true); + ObjectNode durationMarkLine = mapper.createObjectNode(); + durationSeries.set("markLine", durationMarkLine); + ArrayNode durationMarkData = mapper.createArrayNode(); + durationMarkLine.set("data", durationMarkData); + ObjectNode durationAvgMark = mapper.createObjectNode(); + ObjectNode hideLabel = mapper.createObjectNode(); + hideLabel.put("show", false); + ObjectNode dashLineStyle = mapper.createObjectNode(); + dashLineStyle.put("dashOffset", 50); + dashLineStyle.put("color", "rgba(128, 128, 128, 0.1)"); + ArrayNode lightDashType = mapper.createArrayNode(); + lightDashType.add(5); + lightDashType.add(10); + dashLineStyle.set("type", lightDashType); + durationAvgMark.put("type", "average"); + durationAvgMark.put("name", "Avg"); + durationAvgMark.set("label", hideLabel); + durationAvgMark.set("lineStyle", dashLineStyle); + durationMarkData.add(durationAvgMark); + + float maxDuration = (float)0.0; + for (HistoryTestResultSummary h : history) { + hudson.tasks.test.TestResult to = h.getResultInRun(); + if (maxDuration < to.getDuration()) { + maxDuration = to.getDuration(); + } + } + ObjectNode yAxis = mapper.createObjectNode(); + double mul = 1.0; + double roundMul = 1.0; + String durationStr = "Seconds"; + if (maxDuration < 1e-3) { + durationStr = "Microseconds"; + mul = 1e6; + } else if (maxDuration < 1) { + durationStr = "Milliseconds"; + mul = 1e3; + } else if (maxDuration < 90) { + durationStr = "Seconds"; + roundMul = 1000.0; + mul = 1.0; + } else if (maxDuration < 90 * 60) { + durationStr = "Minutes"; + mul = 1.0d / 60.0d; + roundMul = 100.0; + } else { + durationStr = "Hours"; + mul = 1.0d / 3600.0d; + roundMul = 100.0; + } + yAxis.put("name", "Duration (" + durationStr.toLowerCase() + ")"); + durationSeries.put("name", durationStr); - return new TestResultTrendChart().createFromTestObject(createBuildHistory(testObject), chartModelConfiguration); + int index = 0; + ObjectNode skippedStyle = mapper.createObjectNode(); + skippedStyle.put("color", "gray"); + ObjectNode okStyle = mapper.createObjectNode(); + okStyle.put("color", "rgba(50, 200, 50, 0.8)"); + float tmpMax = 0; + double[] lrX = new double[history.size()]; + double[] lrY = new double[history.size()]; + for (HistoryTestResultSummary h : history) { + hudson.tasks.test.TestResult to = h.getResultInRun(); + lrX[index] = ((double)index); + Run r = h.getRun(); + String fdn = r.getDisplayName(); + domainAxisLabels.add(fdn); + ObjectNode durationColor = mapper.createObjectNode(); + double duration = Math.round(mul * to.getDuration() * roundMul) / roundMul; + tmpMax = Math.max((float)duration, tmpMax); + lrY[index] = (double)(duration); + durationColor.put("value", duration); + if (to.isPassed() || (to.getPassCount() > 0 && to.getFailCount() == 0)) { + durationColor.set("itemStyle", okStyle); + } else { + if (to.getFailCount() > 0) { + ObjectNode failedStyle = mapper.createObjectNode(); + double k = Math.min(1.0, to.getFailCount() / (to.getTotalCount() * 0.02)); + failedStyle.put("color", "rgba(255, 100, 100, " + (0.5 + 0.5 * k) +")"); + durationColor.set("itemStyle", failedStyle); + } else { + durationColor.set("itemStyle", skippedStyle); + } + } + durationData.add(durationColor); + ++index; + } + + if (EXTRA_GRAPH_MATH_ENABLED) { + createLinearTrend(mapper, series, history, lrX, lrY, "Trend of " + durationStr, "rgba(0, 120, 255, 0.5)", 0.0, Double.MAX_VALUE, 0, 0, roundMul); // "--blue" + createSplineTrend(mapper, series, history, lrX, lrY, "Smooth of " + durationStr, "rgba(120, 50, 255, 0.5)", 0.0, Double.MAX_VALUE, 0, 0, roundMul); // "--indigo" + } + root.set("series", series); + root.set("domainAxisLabels", domainAxisLabels); + root.put("integerRangeAxis", true); + root.put("domainAxisItemName", "Build"); + if (tmpMax > 50) { + root.put("rangeMax", (int)Math.ceil(tmpMax)); + } else if (tmpMax > 0.0) { + root.put("rangeMax", tmpMax); + } else + root.put("rangeMin", 0); + root.set("yAxis", yAxis); + return root; } - @JavaScriptMethod - @SuppressWarnings("unused") // Called by jelly view - public String getTestDurationTrend(String configuration) { - return JACKSON_FACADE.toJson(createTestDurationResultTrend(ChartModelConfiguration.fromJson(configuration))); + private void createLinearTrend(ObjectMapper mapper, ArrayNode series, List history, double[] lrX, double[] lrY, String title, String color, double minV, double maxV, int xAxisIndex, int yAxisIndex, double roundMul) { + if (history.size() < 3) { + return; + } + LeastSquares lr = new LeastSquares(); + double[] cs = lr.calcCoefficients(lrX, lrY); + + ObjectNode lrSeries = mapper.createObjectNode(); + series.add(lrSeries); + lrSeries.put("name", title); + lrSeries.put("preferScreenOrient", "landscape"); + lrSeries.put("type", "line"); + lrSeries.put("symbol", "circle"); + lrSeries.put("symbolSize", 0); + lrSeries.put("xAxisIndex", xAxisIndex); + lrSeries.put("yAxisIndex", yAxisIndex); + ArrayNode lrData = mapper.createArrayNode(); + lrSeries.set("data", lrData); + ObjectNode lrStyle = mapper.createObjectNode(); + lrSeries.set("itemStyle", lrStyle); + lrStyle.put("color", color); + ObjectNode lrAreaStyle = mapper.createObjectNode(); + lrSeries.set("areaStyle", lrAreaStyle); + lrAreaStyle.put("color", "rgba(0, 0, 0, 0)"); + + if (roundMul < 10.0) { + roundMul = 10.0; + } + for (int index = 0; index < history.size(); ++index) { + // Use float to reduce JSON size. + lrData.add((float)(Math.round((cs[0] + index * cs[1]) * roundMul) / roundMul)); + } } - private LinesChartModel createTestDurationResultTrend(ChartModelConfiguration chartModelConfiguration) { - TestResultImpl pluggableStorage = getPluggableStorage(); + private void createSplineTrend(ObjectMapper mapper, ArrayNode series, List history, double[] lrX, double[] lrY, String title, String color, double minV, double maxV, int xAxisIndex, int yAxisIndex, double roundMul) { + if (history.size() < 200) { + return; + } + double windowSize = 100.0; + double rho = Math.min(1.0, 1.0 / Math.max(1, (history.size() / 2))); + if (rho > 0.75) { + return; // Too close to linear + } + SmoothingCubicSpline scs = new SmoothingCubicSpline(lrX, lrY, 0.001); + ObjectNode lrSeries = mapper.createObjectNode(); + series.add(lrSeries); + lrSeries.put("name", title); + lrSeries.put("preferScreenOrient", "landscape"); + lrSeries.put("type", "line"); + lrSeries.put("symbol", "circle"); + lrSeries.put("symbolSize", 0); + lrSeries.put("xAxisIndex", xAxisIndex); + lrSeries.put("yAxisIndex", yAxisIndex); + ArrayNode lrData = mapper.createArrayNode(); + lrSeries.set("data", lrData); + ObjectNode lrStyle = mapper.createObjectNode(); + lrSeries.set("itemStyle", lrStyle); + lrStyle.put("color", color); + ObjectNode lrAreaStyle = mapper.createObjectNode(); + lrSeries.set("areaStyle", lrAreaStyle); + lrAreaStyle.put("color", "rgba(0, 0, 0, 0)"); - if (pluggableStorage != null) { - return new TestResultDurationChart().create(pluggableStorage.getTestDurationResultSummary()); + if (roundMul < 10.0) { + roundMul = 10.0; + } + for (int index = 0; index < history.size(); ++index) { + // Use float to reduce JSON size. + lrData.add((float)(Math.round(scs.evaluate(index) * roundMul) / roundMul)); + } + } + + static boolean EXTRA_GRAPH_MATH_ENABLED = + Boolean.parseBoolean(System.getProperty(History.class.getName() + ".EXTRA_GRAPH_MATH_ENABLED","true")); + + private ObjectNode computeResultTrendJson(List history) { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode root = mapper.createObjectNode(); + ArrayNode domainAxisLabels = mapper.createArrayNode(); + ArrayNode series = mapper.createArrayNode(); + + ObjectNode okSeries = mapper.createObjectNode(); + okSeries.put("name", "Passed"); + okSeries.put("xAxisIndex", 1); + okSeries.put("yAxisIndex", 1); + okSeries.put("type", "line"); + okSeries.put("symbol", "circle"); + okSeries.put("symbolSize", "0"); + okSeries.put("sampling", "lttb"); + ArrayNode okData = mapper.createArrayNode(); + okSeries.set("data", okData); + ObjectNode okStyle = mapper.createObjectNode(); + okSeries.set("itemStyle", okStyle); + okStyle.put("color", "--success-color"); // "rgba(50, 200, 50, 0.8)"); + okSeries.put("stack", "stacked"); + ObjectNode okAreaStyle = mapper.createObjectNode(); + okSeries.set("areaStyle", okAreaStyle); + okAreaStyle.put("normal", true); + + ObjectNode okMarkLine = mapper.createObjectNode(); + okSeries.set("markLine", okMarkLine); + ArrayNode okMarkData = mapper.createArrayNode(); + okMarkLine.set("data", okMarkData); + ObjectNode avgMark = mapper.createObjectNode(); + ObjectNode hideLabel = mapper.createObjectNode(); + hideLabel.put("show", false); + ObjectNode dashLineStyle = mapper.createObjectNode(); + dashLineStyle.put("dashOffset", 50); + dashLineStyle.put("color", "rgba(128, 128, 128, 0.1)"); + ArrayNode lightDashType = mapper.createArrayNode(); + lightDashType.add(5); + lightDashType.add(10); + dashLineStyle.set("type", lightDashType); + avgMark.put("type", "average"); + avgMark.put("name", "Avg"); + avgMark.set("label", hideLabel); + avgMark.set("lineStyle", dashLineStyle); + okMarkData.add(avgMark); + + ObjectNode failSeries = mapper.createObjectNode(); + failSeries.put("name", "Failed"); + failSeries.put("type", "line"); + failSeries.put("symbol", "circle"); + failSeries.put("symbolSize", "0"); + failSeries.put("sampling", "lttb"); + failSeries.put("xAxisIndex", 1); + failSeries.put("yAxisIndex", 1); + ArrayNode failData = mapper.createArrayNode(); + failSeries.set("data", failData); + ObjectNode failStyle = mapper.createObjectNode(); + failSeries.set("itemStyle", failStyle); + failStyle.put("color", "--light-red"); //"rgba(200, 50, 50, 0.8)"); + failSeries.put("stack", "stacked"); + ObjectNode failAreaStyle = mapper.createObjectNode(); + failSeries.set("areaStyle", failAreaStyle); + failAreaStyle.put("normal", true); + + ObjectNode skipSeries = mapper.createObjectNode(); + skipSeries.put("name", "Skipped"); + skipSeries.put("type", "line"); + skipSeries.put("symbol", "circle"); + skipSeries.put("symbolSize", "0"); + skipSeries.put("sampling", "lttb"); + skipSeries.put("xAxisIndex", 1); + skipSeries.put("yAxisIndex", 1); + ArrayNode skipData = mapper.createArrayNode(); + skipSeries.set("data", skipData); + ObjectNode skipStyle = mapper.createObjectNode(); + skipSeries.set("itemStyle", skipStyle); + skipStyle.put("color", "rgba(160, 173, 177, 0.6)"); + skipSeries.put("stack", "stacked"); + ObjectNode skipAreaStyle = mapper.createObjectNode(); + skipSeries.set("areaStyle", skipAreaStyle); + skipAreaStyle.put("normal", true); + + ObjectNode totalSeries = mapper.createObjectNode(); + totalSeries.put("name", "Total"); + totalSeries.put("type", "line"); + totalSeries.put("symbol", "circle"); + totalSeries.put("symbolSize", "0"); + totalSeries.put("sampling", "lttb"); + totalSeries.put("xAxisIndex", 1); + totalSeries.put("yAxisIndex", 1); + ArrayNode totalData = mapper.createArrayNode(); + totalSeries.set("data", totalData); + ObjectNode lineStyle = mapper.createObjectNode(); + totalSeries.set("lineStyle", lineStyle); + lineStyle.put("width", 1); + lineStyle.put("type", "dashed"); + ObjectNode totalStyle = mapper.createObjectNode(); + totalSeries.set("itemStyle", totalStyle); + totalStyle.put("color", "--light-blue"); //"rgba(0, 255, 255, 0.6)"); + + ObjectNode totalAreaStyle = mapper.createObjectNode(); + totalSeries.set("areaStyle", totalAreaStyle); + totalAreaStyle.put("color", "rgba(0, 0, 0, 0)"); + + series.add(skipSeries); + series.add(failSeries); + series.add(okSeries); + series.add(totalSeries); + + int maxTotalCount = 0; + int index = 0; + double[] lrX = new double[history.size()]; + double[] lrY = new double[history.size()]; + for (HistoryTestResultSummary h : history) { + hudson.tasks.test.TestResult to = h.getResultInRun(); + lrX[index] = ((double)index); + Run r = h.getRun(); + String fdn = r.getDisplayName(); + domainAxisLabels.add(fdn); + lrY[index] = ((double)to.getPassCount()); + okData.add(to.getPassCount()); + skipData.add(to.getSkipCount()); + failData.add(to.getFailCount()); + totalData.add(to.getTotalCount()); + if (maxTotalCount < to.getTotalCount()) { + maxTotalCount = to.getTotalCount(); + } + ++index; } - return new TestResultDurationChart().create(createBuildHistory(testObject), chartModelConfiguration); + if (EXTRA_GRAPH_MATH_ENABLED) { + createLinearTrend(mapper, series, history, lrX, lrY, "Trend of Passed", "rgba(50, 50, 255, 0.5)" , 0.0, maxTotalCount, 1, 1, 10.0); // "--dark-blue" + createSplineTrend(mapper, series, history, lrX, lrY, "Smooth of Passed", "rgba(255, 50, 255, 0.5)", 0.0, maxTotalCount, 1, 1, 10.0); // "--purple" + } + + root.set("series", series); + root.set("domainAxisLabels", domainAxisLabels); + root.put("integerRangeAxis", true); + root.put("domainAxisItemName", "Build"); + root.put("rangeMin", 0); + return root; } - private TestObjectIterable createBuildHistory(final TestObject testObject) { - return new TestObjectIterable(testObject); + private ObjectNode computeDistributionJson(List history) { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode root = mapper.createObjectNode(); + ArrayNode series = mapper.createArrayNode(); + ArrayNode domainAxisLabels = mapper.createArrayNode(); + + ObjectNode durationSeries = mapper.createObjectNode(); + durationSeries.put("name", "Build Count"); + durationSeries.put("type", "line"); + durationSeries.put("symbol", "circle"); + durationSeries.put("symbolSize", "0"); + durationSeries.put("sampling", "lttb"); + ArrayNode durationData = mapper.createArrayNode(); + durationSeries.set("data", durationData); + ObjectNode durationStyle = mapper.createObjectNode(); + durationSeries.set("itemStyle", durationStyle); + durationStyle.put("color", "--success-color");//"rgba(50, 200, 50, 0.8)"); + durationSeries.put("stack", "stacked"); + ObjectNode durAreaStyle = mapper.createObjectNode(); + durationSeries.set("areaStyle", durAreaStyle); + durAreaStyle.put("color", "rgba(0,0,0,0)"); + durationSeries.put("smooth", true); + series.add(durationSeries); + + double maxDuration = 0, minDuration = Double.MAX_VALUE; + for (HistoryTestResultSummary h : history) { + hudson.tasks.test.TestResult to = h.getResultInRun(); + if (maxDuration < to.getDuration()) { + maxDuration = to.getDuration(); + } + if (minDuration > to.getDuration()) { + minDuration = to.getDuration(); + } + } + double extraDuration = Math.max(0.001, (maxDuration - minDuration) * 0.05); + minDuration = Math.max(0.0, minDuration - extraDuration); + maxDuration = maxDuration + extraDuration; + int[] counts = new int[100]; + int smoothBuffer = 2; + double[] lrX = new double[counts.length + smoothBuffer * 2 + 1]; + double[] lrY = new double[counts.length + smoothBuffer * 2 + 1]; + double scale = maxDuration - minDuration; + double step = scale / counts.length; + for (HistoryTestResultSummary h : history) { + hudson.tasks.test.TestResult to = h.getResultInRun(); + int idx = smoothBuffer + (int)Math.round((to.getDuration() - minDuration) / step); + int idx2 = Math.max(0, Math.min(idx, lrY.length - 1)); + lrY[idx2]++; + } + for (int i = 0; i < lrY.length; ++i) { + lrX[i] = ((minDuration + (maxDuration - minDuration) / lrY.length * i) / scale * 100.0); + } + + ObjectNode xAxis = mapper.createObjectNode(); + double mul = 1.0; + double roundMul = 1000.0; + if (maxDuration < 1e-3) { + xAxis.put("name", "Duration (microseconds)"); + mul = 1e6; + } else if (maxDuration < 1) { + xAxis.put("name", "Duration (milliseconds)"); + mul = 1e3; + } else if (maxDuration < 90) { + xAxis.put("name", "Duration (seconds)"); + mul = 1.0; + } else if (maxDuration < 90 * 60) { + xAxis.put("name", "Duration (minutes)"); + mul = 1.0d / 60.0d; + roundMul = 100.0; + } else { + xAxis.put("name", "Duration (hours)"); + mul = 1.0d / 3600.0d; + roundMul = 100.0; + } + + int maxBuilds = 0; + SmoothingCubicSpline scs = new SmoothingCubicSpline(lrX, lrY, 0.1); + int smoothPts = counts.length * 4; + double k = (double)counts.length / smoothPts; + final double splineRoundMul = 1000.0; + for (double z = minDuration; z < maxDuration; z += step * k) { + double v = Math.round(splineRoundMul * Math.max(0.0, scs.evaluate(z / scale * 100.0))) / splineRoundMul; + durationData.add((float)v); + maxBuilds = Math.max(maxBuilds, (int)Math.ceil(v)); + // Use float for smaller JSONs. + domainAxisLabels.add((float)(Math.round(mul * z * roundMul) / roundMul)); + } + + root.set("series", series); + root.put("integerRangeAxis", true); + root.put("domainAxisItemName", "Number of Builds"); + root.set("domainAxisLabels", domainAxisLabels); + root.set("xAxis", xAxis); + if (maxBuilds >= 10) { + root.put("rangeMax", maxBuilds); + } + return root; + } + + private ObjectNode computeBuildMapJson(List history) { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode buildMap = mapper.createObjectNode(); + for (HistoryTestResultSummary h : history) { + Run r = h.getRun(); + String fdn = r.getDisplayName(); + ObjectNode buildObj = mapper.createObjectNode(); + buildObj.put("url", h.getUrl()); + buildMap.set(fdn, buildObj); + } + return buildMap; + } + + private ObjectNode computeTrendJsons(HistoryParseResult parseResult) { + List history = parseResult.historySummaries; + Collections.reverse(history); + ObjectMapper mapper = new ObjectMapper(); + ObjectNode root = mapper.createObjectNode(); + root.set("duration", computeDurationTrendJson(history)); + root.set("result", computeResultTrendJson(history)); + root.set("distribution", computeDistributionJson(history)); + root.set("buildMap", computeBuildMapJson(history)); + ObjectNode saveAsImage = mapper.createObjectNode(); + if (history.size() > 0) { + saveAsImage.put("name", "test-history-" + history.get(0).getRun().getParent().getFullName() + "-" + history.get(0).getRun().getNumber() + "-" + history.get(history.size() - 1).getRun().getNumber()); + } else { + saveAsImage.put("name", "test-history"); + } + root.set("saveAsImage", saveAsImage); + ObjectNode status = mapper.createObjectNode(); + status.put("hasTimedOut", parseResult.hasTimedOut); + status.put("buildsRequested", parseResult.buildsRequested); + status.put("buildsParsed", parseResult.buildsParsed); + status.put("buildsWithTestResult", parseResult.buildsWithTestResult); + root.set("status", status); + return root; + } + + private TestObjectIterable createBuildHistory(final TestObject testObject, int start, int end) { + HistoryTableResult r = retrieveHistorySummary(start, end); + if (r.getHistorySummaries().size() != 0) { + return new TestObjectIterable(testObject, r.getHistorySummaries()); + } + return null; } private TestResultImpl getPluggableStorage() { @@ -126,11 +599,14 @@ private TestResultImpl getPluggableStorage() { public static class HistoryTableResult { private boolean descriptionAvailable; private List historySummaries; + private String trendChartJson; + public HistoryParseResult parseResult; - public HistoryTableResult(List historySummaries) { - this.descriptionAvailable = historySummaries.stream() - .anyMatch(summary -> summary.getDescription() != null); - this.historySummaries = historySummaries; + public HistoryTableResult(HistoryParseResult parseResult, ObjectNode json) { + this.historySummaries = parseResult.historySummaries; + this.descriptionAvailable = this.historySummaries.stream().anyMatch(summary -> summary.getDescription() != null); + this.trendChartJson = json.toString(); + this.parseResult = parseResult; } public boolean isDescriptionAvailable() { @@ -140,42 +616,114 @@ public boolean isDescriptionAvailable() { public List getHistorySummaries() { return historySummaries; } + + public String getTrendChartJson() { + return trendChartJson; + } } - public HistoryTableResult retrieveHistorySummary(int userOffset) { - int offset = userOffset; - if (userOffset > 1000 || userOffset < 0) { - offset = 0; + public static class HistoryParseResult { + List historySummaries; + int buildsRequested; + int buildsParsed; + int buildsWithTestResult; + int start; + int end; + int interval; + boolean hasTimedOut; + public HistoryParseResult(List historySummaries, int buildsRequested, int buildsParsed, int buildsWithTestResult, boolean hasTimedOut, int start, int end, int interval) { + this.buildsRequested = buildsRequested; + this.historySummaries = historySummaries; + this.buildsParsed = buildsParsed; + this.buildsWithTestResult = buildsWithTestResult; + this.hasTimedOut = hasTimedOut; + this.start = start; + this.end = end; + this.interval = interval; } + public HistoryParseResult(List historySummaries, int buildsRequested, int start, int end) { + this(historySummaries, buildsRequested, -1, -1, false, start, end, 1); + } + } - TestResultImpl pluggableStorage = getPluggableStorage(); + // Handle multiple consecutive requests to same data from Jelly. + private Object cachedResultLock = new Object(); + private SoftReference cachedResult = new SoftReference<>(null); - if (pluggableStorage != null) { - return new HistoryTableResult(pluggableStorage.getHistorySummary(offset)); + public HistoryTableResult retrieveHistorySummary(int start, int end) { + return retrieveHistorySummary(start, end, 1); + } + + public HistoryTableResult retrieveHistorySummary(int start, int end, int interval) { + synchronized (cachedResultLock) { + HistoryTableResult result = cachedResult.get(); + if (result != null && result.parseResult.start == start && result.parseResult.end == end && result.parseResult.interval == interval) { + return result; + } + TestResultImpl pluggableStorage = getPluggableStorage(); + HistoryParseResult parseResult = null; + if (pluggableStorage != null) { + int offset = start; + if (start > 1000 || start < 0) { + offset = 0; + } + parseResult = new HistoryParseResult(pluggableStorage.getHistorySummary(offset), end - start + 1, start, end); + } else { + parseResult = getHistoryFromFileStorage(start, end, interval); + } + result = new HistoryTableResult(parseResult, computeTrendJsons(parseResult)); + cachedResult = new SoftReference<>(result); + return result; } - return new HistoryTableResult(getHistoryFromFileStorage()); } - private List getHistoryFromFileStorage() { + static int parallelism = Math.min(Runtime.getRuntime().availableProcessors(), Math.max(4, (int)(Runtime.getRuntime().availableProcessors() * 0.75 * 0.75))); + static ExecutorService executor = Executors.newFixedThreadPool(Math.max(4, (int)(Runtime.getRuntime().availableProcessors() * 0.75 * 0.75))); + static long MAX_TIME_ELAPSED_RETRIEVING_HISTORY_NS = + SystemProperties.getLong(History.class.getName() + ".MAX_TIME_ELAPSED_RETRIEVING_HISTORY_MS", 15000L) * 1000000L; + static int MAX_THREADS_RETRIEVING_HISTORY = + SystemProperties.getInteger(History.class.getName() + ".MAX_THREADS_RETRIEVING_HISTORY",-1); + + private HistoryParseResult getHistoryFromFileStorage(int start, int end, int interval) { TestObject testObject = getTestObject(); RunList builds = testObject.getRun().getParent().getBuilds(); - return builds - .stream() - .map(build -> { - hudson.tasks.test.TestResult resultInRun = testObject.getResultInRun(build); - if (resultInRun == null) { - return null; - } - - return new HistoryTestResultSummary(build, resultInRun.getDuration(), - resultInRun.getFailCount(), - resultInRun.getSkipCount(), - resultInRun.getPassCount(), - resultInRun.getDescription() - ); - }) - .filter(Objects::nonNull) - .collect(Collectors.toList()); + final int requestedCount = end - start; + final AtomicBoolean hasTimedOut = new AtomicBoolean(false); + final AtomicInteger parsedCount = new AtomicInteger(0); + final long startedNs = java.lang.System.nanoTime(); + final AtomicInteger orderedCount = new AtomicInteger(0); + List history = builds.stream() + .skip(start) + .limit(requestedCount) + .filter(build -> { + if (interval == 1) { + return true; + } + int n = orderedCount.getAndIncrement(); + return (n % interval) == 0; + }) + .collect(ParallelCollectors.parallel(build -> { + // Do not navigate too far or for too long, we need to finish the request this year and have to think about RAM + if ((java.lang.System.nanoTime() - startedNs) > MAX_TIME_ELAPSED_RETRIEVING_HISTORY_NS) { + hasTimedOut.set(true); + return null; + } + parsedCount.incrementAndGet(); + hudson.tasks.test.TestResult resultInRun = testObject.getResultInRun(build); + if (resultInRun == null) { + return null; + } + return new HistoryTestResultSummary(build, resultInRun, resultInRun.getDuration(), + resultInRun.getFailCount(), + resultInRun.getSkipCount(), + resultInRun.getPassCount(), + resultInRun.getDescription() + ); + }, executor, MAX_THREADS_RETRIEVING_HISTORY < 1 ? parallelism : Math.min(parallelism, MAX_THREADS_RETRIEVING_HISTORY))) + .join() + .filter(Objects::nonNull) + .collect(Collectors.toList()); + return new HistoryParseResult(history, requestedCount, parsedCount.get(), history.size(), hasTimedOut.get(), start, end, interval); } @SuppressWarnings("unused") // Called by jelly view diff --git a/src/main/java/hudson/tasks/junit/HistoryTestResultSummary.java b/src/main/java/hudson/tasks/junit/HistoryTestResultSummary.java index e4f250652..3cd4c6398 100644 --- a/src/main/java/hudson/tasks/junit/HistoryTestResultSummary.java +++ b/src/main/java/hudson/tasks/junit/HistoryTestResultSummary.java @@ -11,24 +11,31 @@ public class HistoryTestResultSummary { private final int skipCount; private final int passCount; private final String description; + private final hudson.tasks.test.TestResult resultInRun; - public HistoryTestResultSummary(Run run, float duration, int failCount, int skipCount, int passCount) { - this(run, duration, failCount, skipCount, passCount, null); + public HistoryTestResultSummary(Run run, hudson.tasks.test.TestResult resultInRun, + float duration, int failCount, int skipCount, int passCount) { + this(run, resultInRun, duration, failCount, skipCount, passCount, null); } - public HistoryTestResultSummary(Run run, float duration, int failCount, int skipCount, int passCount, String description) { + public HistoryTestResultSummary(Run run, hudson.tasks.test.TestResult resultInRun, float duration, int failCount, int skipCount, int passCount, String description) { this.run = run; this.duration = duration; this.failCount = failCount; this.skipCount = skipCount; this.passCount = passCount; this.description = description; + this.resultInRun = resultInRun; } public String getDescription() { return description; } + public Run getRun() { + return run; + } + public String getDurationString() { return Util.getTimeSpanString((long) (duration * 1000)); } @@ -49,12 +56,19 @@ public int getTotalCount() { return passCount + skipCount + failCount; } + public float getBadness() { + return (float)Math.min(1.0, failCount / (getTotalCount() * 0.02)); + } + public String getFullDisplayName() { return run.getFullDisplayName(); } public String getUrl() { - // TODO may want to try get the test object here - return run.getUrl() + "testReport/junit"; + return resultInRun.getUrl(); + } + + public hudson.tasks.test.TestResult getResultInRun() { + return resultInRun; } } diff --git a/src/main/java/hudson/tasks/junit/JUnitParser.java b/src/main/java/hudson/tasks/junit/JUnitParser.java index 57b94506d..649fa6634 100644 --- a/src/main/java/hudson/tasks/junit/JUnitParser.java +++ b/src/main/java/hudson/tasks/junit/JUnitParser.java @@ -55,13 +55,14 @@ public class JUnitParser extends TestResultParser { private final StdioRetention stdioRetention; private final boolean keepProperties; private final boolean allowEmptyResults; + private final boolean keepTestNames; private final boolean skipOldReports; /** Generally unused, but present for extension compatibility. */ @Deprecated public JUnitParser() { - this(false, false); + this(false, false, false, false); } /** @@ -80,18 +81,24 @@ public JUnitParser(boolean keepLongStdio) { */ @Deprecated public JUnitParser(boolean keepLongStdio, boolean allowEmptyResults) { - this(keepLongStdio, false, allowEmptyResults, false); + this(StdioRetention.fromKeepLongStdio(keepLongStdio), false, allowEmptyResults, false, false); } @Deprecated public JUnitParser(boolean keepLongStdio, boolean keepProperties, boolean allowEmptyResults, boolean skipOldReports) { - this(StdioRetention.fromKeepLongStdio(keepLongStdio), keepProperties, allowEmptyResults, skipOldReports); + this(StdioRetention.fromKeepLongStdio(keepLongStdio), keepProperties, allowEmptyResults, skipOldReports, false); } + @Deprecated public JUnitParser(StdioRetention stdioRetention, boolean keepProperties, boolean allowEmptyResults, boolean skipOldReports) { + this(stdioRetention, keepProperties, allowEmptyResults, skipOldReports, false); + } + + public JUnitParser(StdioRetention stdioRetention, boolean keepProperties, boolean allowEmptyResults, boolean skipOldReports, boolean keepTestNames) { this.stdioRetention = stdioRetention; this.keepProperties = keepProperties; this.allowEmptyResults = allowEmptyResults; + this.keepTestNames = keepTestNames; this.skipOldReports = skipOldReports; } @@ -122,14 +129,14 @@ public TestResult parseResult(String testResultLocations, Run build, FilePa public TestResult parseResult(String testResultLocations, Run build, PipelineTestDetails pipelineTestDetails, FilePath workspace, Launcher launcher, TaskListener listener) throws InterruptedException, IOException { - return workspace.act(new DirectParseResultCallable(testResultLocations, build, stdioRetention, keepProperties, allowEmptyResults, + return workspace.act(new DirectParseResultCallable(testResultLocations, build, stdioRetention, keepProperties, allowEmptyResults, keepTestNames, pipelineTestDetails, listener, skipOldReports)); } public TestResultSummary summarizeResult(String testResultLocations, Run build, PipelineTestDetails pipelineTestDetails, FilePath workspace, Launcher launcher, TaskListener listener, JunitTestResultStorage storage) throws InterruptedException, IOException { - return workspace.act(new StorageParseResultCallable(testResultLocations, build, stdioRetention, keepProperties, allowEmptyResults, + return workspace.act(new StorageParseResultCallable(testResultLocations, build, stdioRetention, keepProperties, allowEmptyResults, keepTestNames, pipelineTestDetails, listener, storage.createRemotePublisher(build), skipOldReports)); } @@ -144,6 +151,7 @@ private static abstract class ParseResultCallable extends MasterToSlaveFileCa private final long nowMaster; private final StdioRetention stdioRetention; private final boolean keepProperties; + private final boolean keepTestNames; private final boolean allowEmptyResults; private final PipelineTestDetails pipelineTestDetails; private final TaskListener listener; @@ -151,7 +159,7 @@ private static abstract class ParseResultCallable extends MasterToSlaveFileCa private boolean skipOldReports; private ParseResultCallable(String testResults, Run build, - StdioRetention stdioRetention, boolean keepProperties, boolean allowEmptyResults, + StdioRetention stdioRetention, boolean keepProperties, boolean allowEmptyResults, boolean keepTestNames, PipelineTestDetails pipelineTestDetails, TaskListener listener, boolean skipOldReports) { this.buildStartTimeInMillis = build.getStartTimeInMillis(); @@ -160,6 +168,7 @@ private ParseResultCallable(String testResults, Run build, this.nowMaster = System.currentTimeMillis(); this.stdioRetention = stdioRetention; this.keepProperties = keepProperties; + this.keepTestNames = keepTestNames; this.allowEmptyResults = allowEmptyResults; this.pipelineTestDetails = pipelineTestDetails; this.listener = listener; @@ -182,7 +191,7 @@ public T invoke(File ws, VirtualChannel channel) throws IOException { + ",buildTimeInMillis:" + buildTimeInMillis + ",filesTimestamp:" + filesTimestamp + ",nowSlave:" + nowSlave + ",nowMaster:" + nowMaster); } - result = new TestResult(filesTimestamp, ds, stdioRetention, keepProperties, pipelineTestDetails, skipOldReports); + result = new TestResult(filesTimestamp, ds, stdioRetention, keepProperties, keepTestNames, pipelineTestDetails, skipOldReports); result.tally(); } else { if (this.allowEmptyResults) { @@ -202,9 +211,9 @@ public T invoke(File ws, VirtualChannel channel) throws IOException { private static final class DirectParseResultCallable extends ParseResultCallable { - DirectParseResultCallable(String testResults, Run build, StdioRetention stdioRetention, boolean keepProperties, boolean allowEmptyResults, + DirectParseResultCallable(String testResults, Run build, StdioRetention stdioRetention, boolean keepProperties, boolean allowEmptyResults, boolean keepTestNames, PipelineTestDetails pipelineTestDetails, TaskListener listener, boolean skipOldReports) { - super(testResults, build, stdioRetention, keepProperties, allowEmptyResults, pipelineTestDetails, listener, skipOldReports); + super(testResults, build, stdioRetention, keepProperties, allowEmptyResults, keepTestNames, pipelineTestDetails, listener, skipOldReports); } @Override @@ -218,9 +227,9 @@ private static final class StorageParseResultCallable extends ParseResultCallabl private final RemotePublisher publisher; - StorageParseResultCallable(String testResults, Run build, StdioRetention stdioRetention, boolean keepProperties, boolean allowEmptyResults, + StorageParseResultCallable(String testResults, Run build, StdioRetention stdioRetention, boolean keepProperties, boolean allowEmptyResults, boolean keepTestNames, PipelineTestDetails pipelineTestDetails, TaskListener listener, RemotePublisher publisher, boolean skipOldReports) { - super(testResults, build, stdioRetention, keepProperties, allowEmptyResults, pipelineTestDetails, listener, skipOldReports); + super(testResults, build, stdioRetention, keepProperties, allowEmptyResults, keepTestNames, pipelineTestDetails, listener, skipOldReports); this.publisher = publisher; } diff --git a/src/main/java/hudson/tasks/junit/JUnitResultArchiver.java b/src/main/java/hudson/tasks/junit/JUnitResultArchiver.java index ee00c5ff6..b62bcff6a 100644 --- a/src/main/java/hudson/tasks/junit/JUnitResultArchiver.java +++ b/src/main/java/hudson/tasks/junit/JUnitResultArchiver.java @@ -86,6 +86,11 @@ public class JUnitResultArchiver extends Recorder implements SimpleBuildStep, JU private String stdioRetention; private boolean keepProperties; + /** + * If true, retain the original test name (do not prepend the parallel stage name). + */ + private boolean keepTestNames; + /** * {@link TestDataPublisher}s configured for this archiver, to process the recorded data. * For compatibility reasons, can be null. @@ -156,7 +161,7 @@ private static TestResult parse(@NonNull JUnitTask task, PipelineTestDetails pip String expandedTestResults, Run run, @NonNull FilePath workspace, Launcher launcher, TaskListener listener) throws IOException, InterruptedException { - return new JUnitParser(task.getParsedStdioRetention(), task.isKeepProperties(), task.isAllowEmptyResults(), task.isSkipOldReports()) + return new JUnitParser(task.getParsedStdioRetention(), task.isKeepProperties(), task.isAllowEmptyResults(), task.isSkipOldReports(), task.isKeepTestNames()) .parseResult(expandedTestResults, run, pipelineTestDetails, workspace, launcher, listener); } @@ -255,7 +260,7 @@ public static TestResultSummary parseAndSummarize(@NonNull JUnitTask task, Pipel summary = null; // see below } else { result = new TestResult(storage.load(build.getParent().getFullName(), build.getNumber())); // irrelevant - summary = new JUnitParser(task.getParsedStdioRetention(), task.isKeepProperties(), task.isAllowEmptyResults(), task.isSkipOldReports()) + summary = new JUnitParser(task.getParsedStdioRetention(), task.isKeepProperties(), task.isAllowEmptyResults(), task.isSkipOldReports(), task.isKeepTestNames()) .summarizeResult(testResults, build, pipelineTestDetails, workspace, launcher, listener, storage); } @@ -423,6 +428,20 @@ public boolean isKeepProperties() { this.keepProperties = keepProperties; } + /** + * @return the keepTestNames + */ + public boolean isKeepTestNames() { + return keepTestNames; + } + + /** + * @param keepTestNames Whether to avoid prepending the parallel stage name to test name. + */ + @DataBoundSetter public final void setKeepTestNames(boolean keepTestNames) { + this.keepTestNames = keepTestNames; + } + /** * * @return the allowEmptyResults diff --git a/src/main/java/hudson/tasks/junit/JUnitTask.java b/src/main/java/hudson/tasks/junit/JUnitTask.java index 48c0764bf..4161d1753 100644 --- a/src/main/java/hudson/tasks/junit/JUnitTask.java +++ b/src/main/java/hudson/tasks/junit/JUnitTask.java @@ -20,6 +20,8 @@ default StdioRetention getParsedStdioRetention() { boolean isKeepProperties(); + boolean isKeepTestNames(); + boolean isAllowEmptyResults(); boolean isSkipPublishingChecks(); diff --git a/src/main/java/hudson/tasks/junit/SuiteResult.java b/src/main/java/hudson/tasks/junit/SuiteResult.java index a405e63d1..8de90c8cd 100644 --- a/src/main/java/hudson/tasks/junit/SuiteResult.java +++ b/src/main/java/hudson/tasks/junit/SuiteResult.java @@ -31,6 +31,7 @@ import org.dom4j.Element; import org.dom4j.io.SAXReader; import org.jenkinsci.plugins.workflow.graph.FlowNode; +import org.jenkinsci.plugins.workflow.graph.FlowNode.*; import org.kohsuke.stapler.export.Exported; import org.kohsuke.stapler.export.ExportedBean; import org.xml.sax.SAXException; @@ -40,6 +41,7 @@ import java.io.File; import java.io.FileInputStream; import java.io.IOException; +import java.io.Reader; import java.io.Serializable; import java.time.OffsetDateTime; import java.util.ArrayList; @@ -49,10 +51,14 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamReader; + /** * Result of one test suite. * @@ -69,13 +75,13 @@ @ExportedBean public final class SuiteResult implements Serializable { private static final Logger LOGGER = Logger.getLogger(SuiteResult.class.getName()); - private final String file; - private final String name; - private final String stdout; - private final String stderr; + private String file; + private String name; + private String stdout; + private String stderr; private float duration; private long startTime; - private final Map properties; + private Map properties; /** * The 'timestamp' attribute of the test suite. @@ -97,14 +103,14 @@ public final class SuiteResult implements Serializable { */ private String nodeId; - private final List enclosingBlocks = new ArrayList<>(); + private List enclosingBlocks = new ArrayList<>(); - private final List enclosingBlockNames = new ArrayList<>(); + private List enclosingBlockNames = new ArrayList<>(); /** * All test cases. */ - private final List cases = new ArrayList<>(); + private List cases = new ArrayList<>(); private transient Map casesByName; private transient hudson.tasks.junit.TestResult parent; @@ -132,6 +138,157 @@ public SuiteResult(String name, String stdout, String stderr, @CheckForNull Pipe this.properties = Collections.emptyMap(); } + public SuiteResult(SuiteResult src) { + this.file = src.file; + this.name = src.name; + this.id = src.id; + this.duration = src.duration; + this.timestamp = src.timestamp; + this.time = src.time; + this.nodeId = src.nodeId; + this.enclosingBlocks = new ArrayList(src.enclosingBlocks); + this.enclosingBlockNames = new ArrayList(src.enclosingBlockNames); + this.stdout = src.stdout; + this.stderr = src.stderr; + if (src.cases == null) { + this.cases = null; + } else { + this.cases = new ArrayList<>(); + for (CaseResult cr : src.cases) { + cases.add(new CaseResult(cr)); + } + } + this.properties = new HashMap<>(); + this.properties.putAll(src.properties); + } + + public static SuiteResult parse(final XMLStreamReader reader, String ver) throws XMLStreamException { + SuiteResult r = new SuiteResult("", null, null, null); + while (reader.hasNext()) { + final int event = reader.next(); + if (event == XMLStreamReader.END_ELEMENT && reader.getLocalName().equals("suite")) { + return r; + } + if (event == XMLStreamReader.START_ELEMENT) { + final String elementName = reader.getLocalName(); + switch (elementName) { + case "cases": + parseCases(r, reader, ver); + break; + case "file": + r.file = reader.getElementText(); + break; + case "name": + r.name = reader.getElementText(); + break; + case "id": + r.id = reader.getElementText(); + break; + case "duration": + r.duration = Math.max(0.0f, Math.min(365 * 24 * 60 * 60, new TimeToFloat(reader.getElementText()).parse())); + break; + case "startTime": + r.startTime = Long.parseLong(reader.getElementText()); + break; + case "timestamp": + r.timestamp = reader.getElementText(); + break; + case "time": + r.time = reader.getElementText(); + break; + case "nodeId": + r.nodeId = reader.getElementText(); + break; + case "enclosingBlocks": + parseEnclosingBlocks(r, reader, ver); + break; + case "enclosingBlockNames": + parseEnclosingBlockNames(r, reader, ver); + break; + case "stdout": + r.stdout = reader.getElementText(); + break; + case "stderr": + r.stderr = reader.getElementText(); + break; + case "properties": + r.properties = new HashMap<>(); + CaseResult.parseProperties(r.properties, reader, ver); + break; + default: + if (LOGGER.isLoggable(Level.FINEST)) { + LOGGER.finest("SuiteResult.parse encountered an unknown field: " + elementName); + } + } + } + } + return r; + } + + public static void parseEnclosingBlocks(SuiteResult r, final XMLStreamReader reader, String ver) throws XMLStreamException { + while (reader.hasNext()) { + final int event = reader.next(); + if (event == XMLStreamReader.END_ELEMENT && reader.getLocalName().equals("enclosingBlocks")) { + return; + } + if (event == XMLStreamReader.START_ELEMENT) { + final String elementName = reader.getLocalName(); + switch (elementName) { + case "string": + r.enclosingBlocks.add(reader.getElementText()); + break; + default: + if (LOGGER.isLoggable(Level.FINEST)) { + LOGGER.finest("SuiteResult.parseEnclosingBlocks encountered an unknown field: " + elementName); + } + } + } + } + } + + public static void parseEnclosingBlockNames(SuiteResult r, final XMLStreamReader reader, String ver) throws XMLStreamException { + while (reader.hasNext()) { + final int event = reader.next(); + if (event == XMLStreamReader.END_ELEMENT && reader.getLocalName().equals("enclosingBlockNames")) { + return; + } + if (event == XMLStreamReader.START_ELEMENT) { + final String elementName = reader.getLocalName(); + switch (elementName) { + case "string": + r.enclosingBlockNames.add(reader.getElementText()); + break; + default: + if (LOGGER.isLoggable(Level.FINEST)) { + LOGGER.finest("SuiteResult.parseEnclosingBlockNames encountered an unknown field: " + elementName); + } + } + } + } + } + + public static void parseCases(SuiteResult r, final XMLStreamReader reader, String ver) throws XMLStreamException { + while (reader.hasNext()) { + final int event = reader.next(); + if (event == XMLStreamReader.END_ELEMENT && reader.getLocalName().equals("cases")) { + return; + } + if (event == XMLStreamReader.START_ELEMENT) { + final String elementName = reader.getLocalName(); + switch (elementName) { + case "case": + r.cases.add(CaseResult.parse(r, reader, ver)); + break; + default: + if (LOGGER.isLoggable(Level.FINEST)) { + LOGGER.finest("SuiteResult.parseCases encountered an unknown field: " + elementName); + } + } + } + } + + } + private synchronized Map casesByName() { if (casesByName == null) { casesByName = new HashMap<>(); @@ -160,13 +317,19 @@ public static class SuiteResultParserConfigurationContext { @Deprecated static List parse(File xmlReport, boolean keepLongStdio, PipelineTestDetails pipelineTestDetails) throws DocumentException, IOException, InterruptedException { - return parse(xmlReport, keepLongStdio, false, pipelineTestDetails); + return parse(xmlReport, keepLongStdio, false, false, pipelineTestDetails); } @Deprecated static List parse(File xmlReport, boolean keepLongStdio, boolean keepProperties, PipelineTestDetails pipelineTestDetails) throws DocumentException, IOException, InterruptedException { - return parse(xmlReport, StdioRetention.fromKeepLongStdio(keepLongStdio), keepProperties, pipelineTestDetails); + return parse(xmlReport, StdioRetention.fromKeepLongStdio(keepLongStdio), keepProperties, false, pipelineTestDetails); + } + + @Deprecated + static List parse(File xmlReport, boolean keepLongStdio, boolean keepProperties, boolean keepTestNames, PipelineTestDetails pipelineTestDetails) + throws DocumentException, IOException, InterruptedException { + return parse(xmlReport, StdioRetention.fromKeepLongStdio(keepLongStdio), keepProperties, keepTestNames, pipelineTestDetails); } /** @@ -174,7 +337,7 @@ static List parse(File xmlReport, boolean keepLongStdio, boolean ke * This method returns a collection, as a single XML may have multiple <testsuite> * elements wrapped into the top-level <testsuites>. */ - static List parse(File xmlReport, StdioRetention stdioRetention, boolean keepProperties, PipelineTestDetails pipelineTestDetails) + static List parse(File xmlReport, StdioRetention stdioRetention, boolean keepProperties, boolean keepTestNames, PipelineTestDetails pipelineTestDetails) throws DocumentException, IOException, InterruptedException { List r = new ArrayList<>(); @@ -194,7 +357,7 @@ static List parse(File xmlReport, StdioRetention stdioRetention, bo Document result = saxReader.read(xmlReportStream); Element root = result.getRootElement(); - parseSuite(xmlReport, stdioRetention, keepProperties, r, root, pipelineTestDetails); + parseSuite(xmlReport, stdioRetention, keepProperties, keepTestNames, r, root, pipelineTestDetails); } return r; @@ -209,28 +372,28 @@ private static void setFeatureQuietly(SAXReader reader, String feature, boolean } } - private static void parseSuite(File xmlReport, StdioRetention stdioRetention, boolean keepProperties, List r, Element root, + private static void parseSuite(File xmlReport, StdioRetention stdioRetention, boolean keepProperties, boolean keepTestNames, List r, Element root, PipelineTestDetails pipelineTestDetails) throws DocumentException, IOException { // nested test suites List testSuites = root.elements("testsuite"); for (Element suite : testSuites) - parseSuite(xmlReport, stdioRetention, keepProperties, r, suite, pipelineTestDetails); + parseSuite(xmlReport, stdioRetention, keepProperties, keepTestNames, r, suite, pipelineTestDetails); // child test cases // FIXME: do this also if no testcases! if (root.element("testcase") != null || root.element("error") != null) - r.add(new SuiteResult(xmlReport, root, stdioRetention, keepProperties, pipelineTestDetails)); + r.add(new SuiteResult(xmlReport, root, stdioRetention, keepProperties, keepTestNames, pipelineTestDetails)); } private SuiteResult(File xmlReport, Element suite, StdioRetention stdioRetention, @CheckForNull PipelineTestDetails pipelineTestDetails) throws DocumentException, IOException { - this(xmlReport, suite, stdioRetention, false, pipelineTestDetails); + this(xmlReport, suite, stdioRetention, false, false, pipelineTestDetails); } /** * @param xmlReport A JUnit XML report file whose top level element is 'testsuite'. * @param suite The parsed result of {@code xmlReport} */ - private SuiteResult(File xmlReport, Element suite, StdioRetention stdioRetention, boolean keepProperties, @CheckForNull PipelineTestDetails pipelineTestDetails) + private SuiteResult(File xmlReport, Element suite, StdioRetention stdioRetention, boolean keepProperties, boolean keepTestNames, @CheckForNull PipelineTestDetails pipelineTestDetails) throws DocumentException, IOException { this.file = xmlReport.getAbsolutePath(); String name = suite.attributeValue("name"); @@ -261,13 +424,13 @@ private SuiteResult(File xmlReport, Element suite, StdioRetention stdioRetention // check for test suite time attribute if ((this.time = suite.attributeValue("time")) != null) { - duration = new TimeToFloat(this.time).parse(); + duration = Math.max(0.0f, Math.min(365 * 24 * 60 * 60, new TimeToFloat(this.time).parse())); } Element ex = suite.element("error"); if (ex != null) { // according to junit-noframes.xsl l.229, this happens when the test class failed to load - addCase(new CaseResult(this, suite, "", stdioRetention, keepProperties)); + addCase(new CaseResult(this, suite, "", stdioRetention, keepProperties, keepTestNames)); } // offset for start time of cases if none is case timestamp is not specified @@ -293,7 +456,7 @@ private SuiteResult(File xmlReport, Element suite, StdioRetention stdioRetention // one wants to use @name from , // the other wants to use @classname from . - CaseResult caze = new CaseResult(this, e, classname, stdioRetention, keepProperties); + CaseResult caze = new CaseResult(this, e, classname, stdioRetention, keepProperties, keepTestNames); // If timestamp is present for set startTime of new CaseResult. String caseStart = e.attributeValue("timestamp"); diff --git a/src/main/java/hudson/tasks/junit/TestResult.java b/src/main/java/hudson/tasks/junit/TestResult.java index 1c0cd77d3..9c633e5b0 100644 --- a/src/main/java/hudson/tasks/junit/TestResult.java +++ b/src/main/java/hudson/tasks/junit/TestResult.java @@ -24,6 +24,7 @@ package hudson.tasks.junit; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import hudson.XmlFile; import hudson.model.Run; import io.jenkins.plugins.junit.storage.TestResultImpl; import hudson.tasks.test.AbstractTestResultAction; @@ -36,6 +37,7 @@ import java.io.File; import java.io.IOException; import java.io.PrintWriter; +import java.io.Reader; import java.io.StringWriter; import java.nio.file.Files; import java.util.ArrayList; @@ -53,12 +55,20 @@ import java.util.logging.Logger; import java.util.stream.Collectors; import java.util.stream.Stream; + +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamReader; + +import jenkins.util.SystemProperties; import org.apache.tools.ant.DirectoryScanner; import org.dom4j.DocumentException; import org.kohsuke.stapler.StaplerRequest; import org.kohsuke.stapler.StaplerResponse; import org.kohsuke.stapler.export.Exported; +import com.thoughtworks.xstream.XStream; + import edu.umd.cs.findbugs.annotations.NonNull; /** @@ -78,7 +88,7 @@ public final class TestResult extends MetaTabulatedResult { * List of all {@link SuiteResult}s in this test. * This is the core data structure to be persisted in the disk. */ - private final List suites = new ArrayList<>(); + private List suites = new ArrayList<>(); /** * {@link #suites} keyed by their names for faster lookup. Since multiple suites can have the same name, holding a collection. @@ -122,9 +132,10 @@ public final class TestResult extends MetaTabulatedResult { */ private transient List failedTests; - private final StdioRetention stdioRetention; + private StdioRetention stdioRetention; + private boolean keepTestNames; - private final boolean keepProperties; + private boolean keepProperties; // default 3s as it depends on OS some can be good some not really.... public static final long FILE_TIME_PRECISION_MARGIN = Long.getLong(TestResult.class.getName() + "filetime.precision.margin", 3000); @@ -143,6 +154,16 @@ public TestResult() { public TestResult(boolean keepLongStdio) { this.stdioRetention = StdioRetention.fromKeepLongStdio(keepLongStdio); this.keepProperties = false; + this.keepTestNames = false; + impl = null; + } + + // Compatibility to XUnit plugin (and maybe more) + @Deprecated + public TestResult(long buildTime, boolean keepLongStdio) throws IOException { + this.stdioRetention = StdioRetention.fromKeepLongStdio(keepLongStdio); + this.keepTestNames = false; + this.keepProperties = false; impl = null; } @@ -153,7 +174,7 @@ public TestResult(long filesTimestamp, DirectoryScanner results) throws IOExcept @Deprecated public TestResult(long filesTimestamp, DirectoryScanner results, boolean keepLongStdio) throws IOException { - this(filesTimestamp, results, keepLongStdio, null); + this(filesTimestamp, results, StdioRetention.fromKeepLongStdio(keepLongStdio), false, false, null, false); } @Deprecated @@ -176,7 +197,13 @@ public TestResult(long filesTimestamp, DirectoryScanner results, boolean keepLon @Deprecated public TestResult(long filesTimestamp, DirectoryScanner results, boolean keepLongStdio, boolean keepProperties, PipelineTestDetails pipelineTestDetails, boolean skipOldReports) throws IOException { - this(filesTimestamp, results, StdioRetention.fromKeepLongStdio(keepLongStdio), keepProperties, pipelineTestDetails, skipOldReports); + this(filesTimestamp, results, StdioRetention.fromKeepLongStdio(keepLongStdio), keepProperties, false, pipelineTestDetails, skipOldReports); + } + + @Deprecated + public TestResult(long filesTimestamp, DirectoryScanner results, boolean keepLongStdio, boolean keepProperties, boolean keepTestNames, + PipelineTestDetails pipelineTestDetails, boolean skipOldReports) throws IOException { + this(filesTimestamp, results, StdioRetention.fromKeepLongStdio(keepLongStdio), keepProperties, keepTestNames, pipelineTestDetails, skipOldReports); } /** @@ -184,13 +211,16 @@ public TestResult(long filesTimestamp, DirectoryScanner results, boolean keepLon * filtering out all files that were created before the given time. * @param filesTimestamp per default files older than this will be ignored (depending on param skipOldReports) * @param stdioRetention how to retain stdout/stderr for large outputs + * @param keepProperties to keep properties or not + * @param keepTestNames to prepend parallel test stage to test name or not * @param pipelineTestDetails A {@link PipelineTestDetails} instance containing Pipeline-related additional arguments. * @param skipOldReports to parse or not test files older than filesTimestamp */ - public TestResult(long filesTimestamp, DirectoryScanner results, StdioRetention stdioRetention, boolean keepProperties, + public TestResult(long filesTimestamp, DirectoryScanner results, StdioRetention stdioRetention, boolean keepProperties, boolean keepTestNames, PipelineTestDetails pipelineTestDetails, boolean skipOldReports) throws IOException { this.stdioRetention = stdioRetention; this.keepProperties = keepProperties; + this.keepTestNames = keepTestNames; impl = null; this.skipOldReports = skipOldReports; parse(filesTimestamp, results, pipelineTestDetails); @@ -200,6 +230,7 @@ public TestResult(TestResultImpl impl) { this.impl = impl; stdioRetention = StdioRetention.DEFAULT; // irrelevant this.keepProperties = false; // irrelevant + keepTestNames = false; // irrelevant } @CheckForNull @@ -222,6 +253,113 @@ public TestResult getTestResult() { return this; } + public TestResult(TestResult src) { + this.stdioRetention = src.stdioRetention; + this.keepProperties = src.keepProperties; + this.keepTestNames = src.keepTestNames; + this.duration = src.duration; + if (src.suites != null) { + this.suites = new ArrayList(); + for (SuiteResult sr : src.suites) { + suites.add(new SuiteResult(sr)); + } + } else { + this.suites = null; + } + this.impl = null; + } + + static final XMLInputFactory xmlFactory; + static { + xmlFactory = XMLInputFactory.newInstance(); + if (SystemProperties.getBoolean(TestResult.class.getName() + ".USE_SAFE_XML_FACTORY", true)) { + xmlFactory.setProperty(XMLInputFactory.SUPPORT_DTD, Boolean.FALSE); + xmlFactory.setProperty(XMLInputFactory.IS_REPLACING_ENTITY_REFERENCES, Boolean.FALSE); + } + } + + public static XMLInputFactory getXmlFactory() { + return xmlFactory; + } + + public void parse(XmlFile f) throws XMLStreamException, IOException { + try (Reader r = f.readRaw()){ + final XMLStreamReader reader = getXmlFactory().createXMLStreamReader(r); + while (reader.hasNext()) { + final int event = reader.next(); + if (event == XMLStreamReader.START_ELEMENT && reader.getName() + .getLocalPart().equals("result")) { + parseXmlResult(reader); + } + } + r.close(); + } + } + + private void parseXmlResult(final XMLStreamReader reader) throws XMLStreamException { + String ver = reader.getAttributeValue(null, "plugin"); + while (reader.hasNext()) { + int event = reader.next(); + if (event == XMLStreamReader.END_ELEMENT && reader.getLocalName().equals("result")) { + return; + } + if (event == XMLStreamReader.START_ELEMENT) { + final String elementName = reader.getLocalName(); + switch (elementName) { + case "suites": + parseXmlSuites(reader, ver); + break; + case "duration": + duration = CaseResult.clampDuration(new TimeToFloat(reader.getElementText()).parse()); + break; + case "keepLongStdio": + stdioRetention = StdioRetention.fromKeepLongStdio(Boolean.parseBoolean(reader.getElementText())); + break; + case "stdioRetention": + stdioRetention = StdioRetention.parse(reader.getElementText()); + break; + case "keepTestNames": + keepTestNames = Boolean.parseBoolean(reader.getElementText()); + break; + case "keepProperties": + keepProperties = Boolean.parseBoolean(reader.getElementText()); + break; + case "skipOldReports": + skipOldReports = Boolean.parseBoolean(reader.getElementText()); + break; + case "startTime": + startTime = Long.parseLong(reader.getElementText()); + break; + default: + if (LOGGER.isLoggable(Level.FINEST)) { + LOGGER.finest("TestResult.parseXmlResult encountered an unknown field: " + elementName); + } + } + } + } + } + + private void parseXmlSuites(final XMLStreamReader reader, String ver) throws XMLStreamException { + while (reader.hasNext()) { + final int event = reader.next(); + if (event == XMLStreamReader.END_ELEMENT && reader.getLocalName().equals("suites")) { + return; + } + if (event == XMLStreamReader.START_ELEMENT) { + final String elementName = reader.getLocalName(); + switch (elementName) { + case "suite": + suites.add(SuiteResult.parse(reader, ver)); + break; + default: + if (LOGGER.isLoggable(Level.FINEST)) { + LOGGER.finest("TestResult.parseXmlSuites encountered an unknown field: " + elementName); + } + } + } + } + } + @Deprecated public void parse(long filesTimestamp, DirectoryScanner results) throws IOException { parse(filesTimestamp, results, null); @@ -419,7 +557,7 @@ public void parse(File reportFile, PipelineTestDetails pipelineTestDetails) thro throw new IllegalStateException("Cannot reparse using a pluggable impl"); } try { - List suiteResults = SuiteResult.parse(reportFile, stdioRetention, keepProperties, pipelineTestDetails); + List suiteResults = SuiteResult.parse(reportFile, stdioRetention, keepProperties, keepTestNames, pipelineTestDetails); for (SuiteResult suiteResult : suiteResults) add(suiteResult); diff --git a/src/main/java/hudson/tasks/junit/TestResultAction.java b/src/main/java/hudson/tasks/junit/TestResultAction.java index f8d843bce..c580a29c0 100644 --- a/src/main/java/hudson/tasks/junit/TestResultAction.java +++ b/src/main/java/hudson/tasks/junit/TestResultAction.java @@ -40,15 +40,20 @@ import hudson.tasks.test.TestResultProjectAction; import hudson.util.HeapSpaceStringConverter; import hudson.util.XStream2; +import jenkins.util.SystemProperties; import org.kohsuke.stapler.StaplerProxy; import java.io.File; import java.io.IOException; +import java.lang.ref.SoftReference; import java.lang.ref.WeakReference; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.logging.Level; import java.util.logging.Logger; import edu.umd.cs.findbugs.annotations.Nullable; @@ -65,6 +70,7 @@ */ @SuppressFBWarnings(value = "UG_SYNC_SET_UNSYNC_GET", justification = "False positive") public class TestResultAction extends AbstractTestResultAction implements StaplerProxy, SimpleBuildStep.LastBuildAction { + private static final Logger LOGGER = Logger.getLogger(TestResultAction.class.getName()); private transient WeakReference result; /** null only if there is a {@link JunitTestResultStorage} */ @@ -121,6 +127,7 @@ public synchronized void setResult(TestResult result, TaskListener listener) { if (run != null) { // persist the data try { + resultCache.put(getDataFilePath(), new SoftReference(result)); getDataFile().write(result); } catch (IOException e) { e.printStackTrace(listener.fatalError("Failed to save the JUnit test result")); @@ -139,14 +146,19 @@ private XmlFile getDataFile() { return new XmlFile(XSTREAM, new File(run.getRootDir(), "junitResult.xml")); } + private String getDataFilePath() { + return Paths.get(run.getRootDir().getAbsolutePath(), "junitResult.xml").toString(); + } + @Override public synchronized TestResult getResult() { + long started = System.nanoTime(); JunitTestResultStorage storage = JunitTestResultStorage.find(); if (!(storage instanceof FileJunitTestResultStorage)) { return new TestResult(storage.load(run.getParent().getFullName(), run.getNumber())); } TestResult r; - if(result==null) { + if (result==null) { r = load(); result = new WeakReference<>(r); } else { @@ -162,6 +174,10 @@ public synchronized TestResult getResult() { failCount = r.getFailCount(); skipCount = r.getSkipCount(); } + long d = System.nanoTime() - started; + if (d > 500000000L && LOGGER.isLoggable(Level.WARNING)) { + LOGGER.warning("TestResultAction.load took " + d / 1000000L + " ms."); + } return r; } @@ -223,11 +239,44 @@ public List getSkippedTests() { return getResult().getSkippedTests(); } + private TestResult parseOnly() { + XmlFile df = getDataFile(); + TestResult r; + try { + r = new TestResult(); + r.parse(df); + } catch (Exception e) { + logger.log(Level.WARNING, "Failed to load " + df, e); + r = new TestResult(); // return a dummy + } + return r; + } + + static ConcurrentHashMap> resultCache = new ConcurrentHashMap<>(); + static Object syncObj = new Object(); + static long lastCleanupNs = 0; + + static long LARGE_RESULT_CACHE_CLEANUP_INTERVAL_NS = + SystemProperties.getLong(TestResultAction.class.getName() + ".LARGE_RESULT_CACHE_CLEANUP_INTERVAL_MS",500L) * 1000000L; + static int LARGE_RESULT_CACHE_THRESHOLD = + SystemProperties.getInteger(TestResultAction.class.getName() + ".LARGE_RESULT_CACHE_THRESHOLD",1000); + static boolean RESULT_CACHE_ENABLED = + SystemProperties.getBoolean(TestResultAction.class.getName() + ".RESULT_CACHE_ENABLED",true); /** - * Loads a {@link TestResult} from disk. + * Loads a {@link TestResult} from cache or disk. */ private TestResult load() { + if (RESULT_CACHE_ENABLED) { + return loadCached(); + } + return loadFallback(); + } + + /** + * Loads a {@link TestResult} from disk, fallback. + */ + private TestResult loadFallback() { TestResult r; try { r = (TestResult)getDataFile().read(); @@ -239,6 +288,37 @@ private TestResult load() { return r; } + /** + * Loads a {@link TestResult} from cache or disk, optimized. + */ + private TestResult loadCached() { + if (resultCache.size() > LARGE_RESULT_CACHE_THRESHOLD) { + synchronized (syncObj) + { + if (resultCache.size() > LARGE_RESULT_CACHE_THRESHOLD && (System.nanoTime() - lastCleanupNs) > LARGE_RESULT_CACHE_CLEANUP_INTERVAL_NS) { + lastCleanupNs = System.nanoTime(); + resultCache.forEach((String k, SoftReference v) -> { + if (v.get() == null) { + resultCache.remove(k); + } + }); + } + } + } + TestResult r; + String k = getDataFilePath(); + r = resultCache.computeIfAbsent(k, path -> { + return new SoftReference(parseOnly()); + }).get(); + if (r == null) { + r = parseOnly(); + resultCache.replace(k, new SoftReference(r)); + } + r = new TestResult(r); + r.freeze(this); + return r; + } + @Override public Object getTarget() { return getResult(); diff --git a/src/main/java/hudson/tasks/junit/pipeline/JUnitResultsStep.java b/src/main/java/hudson/tasks/junit/pipeline/JUnitResultsStep.java index ce187e259..62885975e 100644 --- a/src/main/java/hudson/tasks/junit/pipeline/JUnitResultsStep.java +++ b/src/main/java/hudson/tasks/junit/pipeline/JUnitResultsStep.java @@ -24,6 +24,7 @@ import org.kohsuke.stapler.QueryParameter; import edu.umd.cs.findbugs.annotations.NonNull; + import java.util.Collections; import java.util.HashSet; import java.util.List; @@ -42,6 +43,11 @@ public class JUnitResultsStep extends Step implements JUnitTask { private boolean keepProperties; + /** + * If true, do not mangle test names in case running in multiple stages or parallel steps. + */ + private boolean keepTestNames; + /** * {@link TestDataPublisher}s configured for this archiver, to process the recorded data. * For compatibility reasons, can be null. @@ -150,6 +156,20 @@ public boolean isKeepProperties() { this.keepProperties = keepProperties; } + /** + * @return the keepTestNames. + */ + public boolean isKeepTestNames() { + return keepTestNames; + } + + /** + * @param keepTestNames Whether to avoid adding parallel stage name into test name. + */ + @DataBoundSetter public final void setKeepTestNames(boolean keepTestNames) { + this.keepTestNames = keepTestNames; + } + /** * * @return the allowEmptyResults diff --git a/src/main/java/hudson/tasks/test/TestDurationTrendSeriesBuilder.java b/src/main/java/hudson/tasks/test/TestDurationTrendSeriesBuilder.java index 42b5b2e23..12cf2980d 100644 --- a/src/main/java/hudson/tasks/test/TestDurationTrendSeriesBuilder.java +++ b/src/main/java/hudson/tasks/test/TestDurationTrendSeriesBuilder.java @@ -15,6 +15,9 @@ protected Map computeSeries(TestObject testObject) { Map series = new HashMap<>(); series.put(SECONDS, (int) testObject.getDuration()); + series.put("failed", (int) testObject.getFailCount()); + series.put("skipped", (int) testObject.getSkipCount()); + series.put("passed", (int) testObject.getPassCount()); return series; } diff --git a/src/main/java/hudson/tasks/test/TestObjectIterable.java b/src/main/java/hudson/tasks/test/TestObjectIterable.java index cf8329ad3..d538bb693 100644 --- a/src/main/java/hudson/tasks/test/TestObjectIterable.java +++ b/src/main/java/hudson/tasks/test/TestObjectIterable.java @@ -5,10 +5,15 @@ import edu.umd.cs.findbugs.annotations.NonNull; import hudson.model.Run; import java.util.Iterator; +import java.util.List; import java.util.NoSuchElementException; +import java.util.stream.Collectors; + +import hudson.tasks.junit.*; public class TestObjectIterable implements Iterable> { private final TestObject latestAction; + private final List items; /** * Creates a new iterator that selects action of the given type {@code actionType}. @@ -18,15 +23,34 @@ public class TestObjectIterable implements Iterable> { */ public TestObjectIterable(final TestObject baseline) { this.latestAction = baseline; + this.items = null; + } + + public TestObjectIterable(final TestObject baseline, List results) { + this.latestAction = baseline; + this.items = results + .stream() + .map(r -> (TestObject)baseline.getResultInRun(r.getRun())) + .filter(r -> r != null) + .collect(Collectors.toList()); } @NonNull @Override public Iterator> iterator() { - if (latestAction == null) { - return new TestResultActionIterator(null); + if (items == null) { + return new TestResultActionIterator(latestAction); } - return new TestResultActionIterator(latestAction); + return items + .stream() + .map(t -> { + Run run = t.getRun(); + int buildTimeInSeconds = (int) (run.getTimeInMillis() / 1000); + Build build = new Build(run.getNumber(), run.getDisplayName(), buildTimeInSeconds); + return new BuildResult<>(build, t); + }) + .collect(Collectors.toList()) + .iterator(); } private static class TestResultActionIterator implements Iterator> { @@ -87,5 +111,4 @@ private TestObject getTestResult() { return run; } } - } diff --git a/src/main/java/hudson/tasks/test/TestResultProjectAction.java b/src/main/java/hudson/tasks/test/TestResultProjectAction.java index f4a9fc42a..91da8b211 100644 --- a/src/main/java/hudson/tasks/test/TestResultProjectAction.java +++ b/src/main/java/hudson/tasks/test/TestResultProjectAction.java @@ -105,16 +105,15 @@ public String getUrlName() { } public AbstractTestResultAction getLastTestResultAction() { - final Run tb = job.getLastSuccessfulBuild(); - + /* + Any build with test results should be considered. + Nowadays pipeline builds can be failed, even though just a substage failed, whereas other stages do produce test results. + Using UNSTABLE is not feasible for this, as that does not mark a build as containing a failure for other systems that list the Jenkins builds externally. + */ Run b = job.getLastBuild(); while(b!=null) { AbstractTestResultAction a = b.getAction(AbstractTestResultAction.class); if(a!=null && (!b.isBuilding())) return a; - if(b==tb) - // if even the last successful build didn't produce the test result, - // that means we just don't have any tests configured. - return null; b = b.getPreviousBuild(); } diff --git a/src/main/java/hudson/tasks/test/TestResultTrendChart.java b/src/main/java/hudson/tasks/test/TestResultTrendChart.java index 40e762009..472d72b90 100644 --- a/src/main/java/hudson/tasks/test/TestResultTrendChart.java +++ b/src/main/java/hudson/tasks/test/TestResultTrendChart.java @@ -2,10 +2,12 @@ import java.util.List; + import edu.hm.hafner.echarts.ChartModelConfiguration; import edu.hm.hafner.echarts.LineSeries; import edu.hm.hafner.echarts.LinesChartModel; import edu.hm.hafner.echarts.LinesDataSet; +import edu.hm.hafner.echarts.SeriesBuilder; import edu.umd.cs.findbugs.annotations.NonNull; import hudson.tasks.junit.TrendTestResultSummary; @@ -54,7 +56,6 @@ public LinesChartModel createFromTestObject(final Iterable results, final ChartM private LinesChartModel getLinesChartModel(final LinesDataSet dataSet, final PassedColor passedColor) { LinesChartModel model = new LinesChartModel(dataSet); - LineSeries passed = new LineSeries("Passed", passedColor == PassedColor.BLUE ? JenkinsPalette.BLUE.normal() : JenkinsPalette.GREEN.normal(), LineSeries.StackedMode.STACKED, LineSeries.FilledMode.FILLED); diff --git a/src/main/resources/hudson/tasks/junit/CaseResult/index.jelly b/src/main/resources/hudson/tasks/junit/CaseResult/index.jelly index 85f791cc4..4b04603e4 100644 --- a/src/main/resources/hudson/tasks/junit/CaseResult/index.jelly +++ b/src/main/resources/hudson/tasks/junit/CaseResult/index.jelly @@ -63,9 +63,9 @@ THE SOFTWARE. - + - +
diff --git a/src/main/resources/hudson/tasks/junit/History/history.js b/src/main/resources/hudson/tasks/junit/History/history.js new file mode 100644 index 000000000..b38b34e25 --- /dev/null +++ b/src/main/resources/hudson/tasks/junit/History/history.js @@ -0,0 +1,409 @@ +/* global jQuery3, bootstrap5, echartsJenkinsApi */ +var start +var end +var count +var interval +var trendChartJson +var appRootUrl +var testObjectUrl +var resultSeries +var durationSeries +var trendChartId = 'test-trend-chart' + +function onBuildWindowChange(selectObj) { + let idx = selectObj.selectedIndex; + let c = selectObj.options[idx].value + document.location = `${appRootUrl}${testObjectUrl}/history?start=${start}&count=${c}&interval=${interval}` +} + +function onBuildIntervalChange(selectObj) { + let idx = selectObj.selectedIndex; + let i = selectObj.options[idx].value + document.location = `${appRootUrl}${testObjectUrl}/history?start=${start}&count=${count}&interval=${i}` +} + +(function ($) { + $(document).ready(function ($) { + let dataEl = document.getElementById("history-data"); + start = dataEl.getAttribute("data-start") + end = dataEl.getAttribute("data-end") + count = dataEl.getAttribute("data-count") + interval = dataEl.getAttribute("data-interval") + let trendChartJsonStr = dataEl.innerHTML + trendChartJson = JSON.parse(trendChartJsonStr) + appRootUrl = dataEl.getAttribute("data-appRootUrl") + testObjectUrl = dataEl.getAttribute("data-testObjectUrl") + + trendChartJsonStr = null + dataEl.setAttribute("data-trendChartJson", "") + + const trendConfigurationDialogId = 'chart-configuration-test-history'; + + $('#' + trendConfigurationDialogId).on('hidden.bs.modal', function () { + redrawTrendCharts(); + }); + + redrawTrendCharts(); + + document.getElementById('history-window').value = count + document.getElementById('history-interval').value = interval + + if (trendChartJson?.status && trendChartJson?.status.buildsWithTestResult < trendChartJson?.status.buildsRequested) { + let s + if (trendChartJson.status.hasTimedOut) { + s = `Too big. Showing ${trendChartJson.status.buildsWithTestResult} results from the most recent ${trendChartJson.status.buildsParsed} out of ` + } else { + s = `Showing ${trendChartJson.status.buildsWithTestResult} test results out of ` + } + document.getElementById("history-info").innerHTML = s; + } + /** + * Activate tooltips. + */ + $(function () { + $('[data-bs-toggle="tooltip"]').each(function () { + const tooltip = new bootstrap5.Tooltip($(this)[0]); + tooltip.enable(); + }); + }); + + function filterTrendSeries() { + let model = trendChartJson + const chartPlaceHolder = document.getElementById(trendChartId); + if (resultSeries === undefined) { + resultSeries = model.result.series + } + if (durationSeries === undefined) { + durationSeries = model.duration.series + } + let r = chartPlaceHolder.getBoundingClientRect() + let aspect = r.width / r.height + let series = durationSeries.concat(resultSeries) + if (aspect < 1.75) { + series = series.filter((s) => !s.preferScreenOrient || s.preferScreenOrient != "landscape") + } + series.forEach(s => s.emphasis = { + disabled: true + }); + return series + } + + function renderTrendChart(chartDivId, model, settingsDialogId, chartClickedEventHandler) { + const chartPlaceHolder = document.getElementById(chartDivId); + const chart = echarts.init(chartPlaceHolder); + chartPlaceHolder.echart = chart; + let style = getComputedStyle(document.body) + const textColor = style.getPropertyValue('--darkreader-text--text-color') || style.getPropertyValue('--text-color') || '#222'; + const showSettings = document.getElementById(settingsDialogId); + let darkMode = style.getPropertyValue('--darkreader-bg--background') + darkMode = darkMode !== undefined && darkMode !== null && darkMode !== '' + const options = { + animation: false, + darkMode: darkMode, + toolbox: { + feature: { + dataZoom: { + yAxisIndex: 'none' + }, + restore: {}, + saveAsImage: { + name: model.saveAsImage.name + } + } + }, + tooltip: { + trigger: 'axis', + animation: false, + axisPointer: { + type: 'cross', + label: { + backgroundColor: '#6a7985' + }, + animation: false + }, + transitionDuration: 0, + textStyle: { + fontSize: 12, + }, + padding: 5, + order: 'seriesAsc', + position: [-260, '7%'], + }, + axisPointer: { + snap: true, + link: [ + { + xAxisIndex: 'all' + } + ] + }, + dataZoom: [ + { + type: 'inside', + xAxisIndex: [0, 1] + }, + { + type: 'slider', + height: 20, + bottom: 5, + moveHandleSize: 4, + xAxisIndex: [0, 1] + } + ], + legend: { + orient: 'horizontal', + type: 'plain', + x: 'center', + y: 'top', + width: '75%', + textStyle: { + color: textColor + }, + selector: ['all', 'inverse'] + }, + grid: [ + { + left: 80, + right: 40, + height: '33%', + top: '12%', + }, + { + left: 80, + right: 40, + top: '53%', + height: '33%' + } + ], + xAxis: [ + { + type: 'category', + boundaryGap: false, + data: model.duration.domainAxisLabels, + axisLabel: { + color: textColor, + show: false + } + }, + { + type: 'category', + gridIndex: 1, + boundaryGap: false, + data: model.result.domainAxisLabels, + axisLabel: { + color: textColor + } + } + ], + yAxis: [ + { + type: 'value', + min: model.duration.rangeMin ?? 'dataMin', + max: model.duration.rangeMax ?? 'dataMax', + axisLabel: { + color: textColor + }, + minInterval: model.duration.integerRangeAxis ? 1 : null, + name: model.duration.yAxis.name, + nameLocation: 'middle', + nameGap: 60, + nameTextStyle: { + color: textColor + }, + splitLine: { + lineStyle: { + color: darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)' + } + } + }, + { + type: 'value', + gridIndex: 1, + min: model.result.rangeMin ?? 'dataMin', + max: model.result.rangeMax ?? 'dataMax', + axisLabel: { + color: textColor + }, + minInterval: model.result.integerRangeAxis ? 1 : null, + name: 'Count', + nameLocation: 'middle', + nameGap: 60, + nameTextStyle: { + color: textColor + }, + splitLine: { + lineStyle: { + color: darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)' + } + } + } + ], + series: filterTrendSeries() + }; + chart.setOption(options); + chart.resize(); + if (chartClickedEventHandler !== null) { + chart.getZr().on('click', params => { + const offset = 30; + if (params.offsetY > offset && chart.getHeight() - params.offsetY > offset) { // skip the legend and data zoom + const pointInPixel = [params.offsetX, params.offsetY]; + const pointInGrid = chart.convertFromPixel('grid', pointInPixel); + const buildDisplayName = chart.getModel().get('xAxis')[0].data[pointInGrid[0]] + chartClickedEventHandler(buildDisplayName); + } + }) + } + } + + function renderDistributionChart(chartDivId, model, settingsDialogId, chartClickedEventHandler) { + const chartPlaceHolder = document.getElementById(chartDivId); + const chart = echarts.init(chartPlaceHolder); + chartPlaceHolder.echart = chart; + let style = getComputedStyle(document.body) + const textColor = style.getPropertyValue('--darkreader-text--text-color') || style.getPropertyValue('--text-color') || '#222'; + const showSettings = document.getElementById(settingsDialogId); + let darkMode = style.getPropertyValue('--darkreader-bg--background') + darkMode = darkMode !== undefined && darkMode !== null && darkMode !== '' + + let series = model.distribution.series + series.forEach(s => s.emphasis = { + disabled: true + }); + const options = { + animation: false, + darkMode: darkMode, + toolbox: { + feature: { + restore: {}, + saveAsImage: { + name: model.saveAsImage.name + '-distribution' + } + } + }, + tooltip: { + trigger: 'axis', + animation: false, + axisPointer: { + type: 'cross', + label: { + backgroundColor: '#6a7985' + }, + animation: false + }, + transitionDuration: 0, + textStyle: { + fontSize: 12, + }, + padding: 5, + order: 'seriesAsc', + position: [-260, '7%'], + }, + axisPointer: { + snap: false + }, + legend: { + orient: 'horizontal', + type: 'scroll', + x: 'center', + y: 'top', + width: '70%', + textStyle: { + color: textColor + }, + }, + grid: { + left: 80, + right: 40, + height: '57%', + top: '20%', + }, + xAxis: { + type: 'category', + boundaryGap: false, + axisLabel: { + color: textColor + }, + data: model.distribution.domainAxisLabels, + name: model.distribution.xAxis.name, + nameLocation: 'middle', + nameGap: 26, + nameTextStyle: { + color: textColor + }, + }, + yAxis: { + type: 'value', + min: 'dataMin', + max: 'dataMax', + axisLabel: { + color: textColor + }, + name: 'Build Count', + nameLocation: 'middle', + nameGap: 60, + nameTextStyle: { + color: textColor + }, + splitLine: { + lineStyle: { + color: darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)' + } + } + }, + series: series + }; + chart.setOption(options); + chart.resize(); + if (chartClickedEventHandler !== null) { + chart.getZr().on('click', params => { + const offset = 30; + if (params.offsetY > offset && chart.getHeight() - params.offsetY > offset) { // skip the legend and data zoom + const pointInPixel = [params.offsetX, params.offsetY]; + const pointInGrid = chart.convertFromPixel('grid', pointInPixel); + const buildDisplayName = chart.getModel().get('xAxis')[0].data[pointInGrid[0]] + chartClickedEventHandler(buildDisplayName); + } + }) + } + } + + function applyCssColors(chartData) { + let style = getComputedStyle(document.body) + chartData.series.forEach((s) => { + if (s?.itemStyle?.color && s.itemStyle.color.startsWith('--')) { + s.itemStyle.color = style.getPropertyValue(s.itemStyle.color) + } + }) + } + /** + * Redraws the trend charts. Reads the last selected X-Axis type from the browser local storage and + * redraws the trend charts. + */ + function redrawTrendCharts() { + applyCssColors(trendChartJson.result) + applyCssColors(trendChartJson.distribution) + applyCssColors(trendChartJson.duration) + /** + * Creates the charts that show the test results, duration and distribution across a number of builds. + */ + // TODO: Improve ECharts plugin to allow more direct interaction with ECharts + renderTrendChart(trendChartId, trendChartJson, trendConfigurationDialogId, + function (buildDisplayName) { + if (trendChartJson.buildMap[buildDisplayName]) { + window.open(rootUrl + trendChartJson.buildMap[buildDisplayName].url); + } + }); + renderDistributionChart('test-distribution-chart', trendChartJson, trendConfigurationDialogId, null); + } + jQuery3(window).resize(function () { + let trendEchart = document.getElementById(trendChartId).echart + trendEchart.setOption({ + series: filterTrendSeries() + }, { + replaceMerge: ['series'] + }) + trendEchart.resize(); + document.getElementById('test-distribution-chart').echart.resize(); + }); + }) +})(jQuery3); diff --git a/src/main/resources/hudson/tasks/junit/History/index.jelly b/src/main/resources/hudson/tasks/junit/History/index.jelly index 6f633981b..d8adf670e 100644 --- a/src/main/resources/hudson/tasks/junit/History/index.jelly +++ b/src/main/resources/hudson/tasks/junit/History/index.jelly @@ -24,84 +24,142 @@ THE SOFTWARE. - + + - + + + + - - + - - - -