diff --git a/panoramapublic/resources/schemas/dbscripts/postgresql/panoramapublic-25.004-25.005.sql b/panoramapublic/resources/schemas/dbscripts/postgresql/panoramapublic-25.004-25.005.sql
new file mode 100644
index 00000000..bef9e999
--- /dev/null
+++ b/panoramapublic/resources/schemas/dbscripts/postgresql/panoramapublic-25.004-25.005.sql
@@ -0,0 +1,14 @@
+-- Dropping ix_datasetstatus_experimentannotations [ExperimentAnnotationsId] because it overlaps with uq_datasetstatus_experimentannotations [ExperimentAnnotationsId]
+DROP INDEX panoramapublic.ix_datasetstatus_experimentannotations;
+-- Dropping ix_experimentannotations_shorturl [ShortUrl] because it overlaps with uq_experimentannotations_shorturl [ShortUrl]
+DROP INDEX panoramapublic.ix_experimentannotations_shorturl;
+-- Dropping ix_speclibinfo_experimentannotations [experimentAnnotationsId] because it overlaps with uq_speclibinfo [experimentAnnotationsId, librarytype, name, filenamehint, skylinelibraryid, revision]
+DROP INDEX panoramapublic.ix_speclibinfo_experimentannotations;
+-- Dropping ix_catalogentry_shorturl [ShortUrl] because it overlaps with uq_catalogentry_shorturl [ShortUrl]
+DROP INDEX panoramapublic.ix_catalogentry_shorturl;
+-- Dropping ix_experimentstructuralmodinfo_experimentannotationsid [ExperimentAnnotationsId] because it overlaps with uq_experimentstructuralmodinfo [ExperimentAnnotationsId, ModId]
+DROP INDEX panoramapublic.ix_experimentstructuralmodinfo_experimentannotationsid;
+-- Dropping ix_journalexperiment_journal [JournalId] because it overlaps with uq_journalexperiment [JournalId, ExperimentAnnotationsId]
+DROP INDEX panoramapublic.ix_journalexperiment_journal;
+-- Dropping ix_experimentisotopemodinfo_experimentannotationsid [ExperimentAnnotationsId] because it overlaps with uq_experimentisotopemodinfo [ExperimentAnnotationsId, ModId]
+DROP INDEX panoramapublic.ix_experimentisotopemodinfo_experimentannotationsid;
diff --git a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicModule.java b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicModule.java
index be3507bd..e483b033 100644
--- a/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicModule.java
+++ b/panoramapublic/src/org/labkey/panoramapublic/PanoramaPublicModule.java
@@ -94,7 +94,7 @@ public String getName()
@Override
public @Nullable Double getSchemaVersion()
{
- return 25.004;
+ return 25.005;
}
@Override
diff --git a/testresults/resources/schemas/dbscripts/postgresql/testresults-13.400-13.401.sql b/testresults/resources/schemas/dbscripts/postgresql/testresults-13.400-13.401.sql
new file mode 100644
index 00000000..51923034
--- /dev/null
+++ b/testresults/resources/schemas/dbscripts/postgresql/testresults-13.400-13.401.sql
@@ -0,0 +1,2 @@
+-- Dropping testrunid_unqiue [id] because it overlaps with pk_testruns [id]
+ALTER TABLE testresults.testruns DROP CONSTRAINT testrunid_unqiue;
diff --git a/testresults/resources/schemas/testresults.xml b/testresults/resources/schemas/testresults.xml
index 770196f2..e4e34b34 100644
--- a/testresults/resources/schemas/testresults.xml
+++ b/testresults/resources/schemas/testresults.xml
@@ -27,7 +27,13 @@
-
+
+
+ testresults
+ testruns
+ id
+
+
@@ -36,7 +42,13 @@
-
+
+
+ testresults
+ testruns
+ id
+
+
@@ -46,7 +58,13 @@
-
+
+
+ testresults
+ testruns
+ id
+
+
@@ -55,7 +73,13 @@
-
+
+
+ testresults
+ testruns
+ id
+
+
@@ -67,7 +91,13 @@
-
+
+
+ testresults
+ testruns
+ id
+
+
@@ -91,7 +121,13 @@
-
+
+
+ testresults
+ user
+ id
+
+
@@ -105,7 +141,13 @@
-
+
+
+ testresults
+ testruns
+ id
+
+
@@ -117,7 +159,13 @@
-
+
+
+ testresults
+ user
+ id
+
+
@@ -126,4 +174,4 @@
-
\ No newline at end of file
+
diff --git a/testresults/src/org/labkey/testresults/TestResultsController.java b/testresults/src/org/labkey/testresults/TestResultsController.java
index 997c28b7..aeebef33 100644
--- a/testresults/src/org/labkey/testresults/TestResultsController.java
+++ b/testresults/src/org/labkey/testresults/TestResultsController.java
@@ -18,6 +18,8 @@
import jakarta.servlet.http.HttpServletRequest;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.ArrayUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.Strings;
import org.apache.commons.lang3.time.DateUtils;
import org.apache.commons.validator.routines.EmailValidator;
import org.apache.logging.log4j.LogManager;
@@ -26,6 +28,7 @@
import org.labkey.api.action.ApiSimpleResponse;
import org.labkey.api.action.MutatingApiAction;
import org.labkey.api.action.ReadOnlyApiAction;
+import org.labkey.api.action.SimpleErrorView;
import org.labkey.api.action.SimpleViewAction;
import org.labkey.api.action.SpringActionController;
import org.labkey.api.collections.IntHashMap;
@@ -51,15 +54,17 @@
import org.labkey.api.security.RequiresSiteAdmin;
import org.labkey.api.security.UserManager;
import org.labkey.api.security.ValidEmail;
+import org.labkey.api.security.permissions.AdminOperationsPermission;
import org.labkey.api.security.permissions.AdminPermission;
import org.labkey.api.security.permissions.ReadPermission;
import org.labkey.api.util.FileUtil;
import org.labkey.api.util.MimeMap;
import org.labkey.api.util.Pair;
import org.labkey.api.util.XmlBeansUtil;
+import org.labkey.api.view.ActionURL;
import org.labkey.api.view.JspView;
import org.labkey.api.view.NavTree;
-import org.labkey.api.view.ViewContext;
+import org.labkey.api.view.WebPartView;
import org.labkey.testresults.model.GlobalSettings;
import org.labkey.testresults.model.RunDetail;
import org.labkey.testresults.model.TestFailDetail;
@@ -81,7 +86,6 @@
import org.quartz.Trigger;
import org.quartz.TriggerBuilder;
import org.quartz.impl.StdSchedulerFactory;
-import org.springframework.util.StringUtils;
import org.springframework.validation.BindException;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartRequest;
@@ -92,14 +96,18 @@
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
-import javax.management.modelmbean.XMLParseException;
import javax.xml.parsers.DocumentBuilder;
+import javax.xml.transform.Transformer;
+import javax.xml.transform.TransformerFactory;
+import javax.xml.transform.dom.DOMSource;
+import javax.xml.transform.stream.StreamResult;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.io.StringReader;
+import java.io.StringWriter;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.text.ParseException;
@@ -133,6 +141,8 @@ public class TestResultsController extends SpringActionController
private static final Logger _log = LogManager.getLogger(TestResultsController.class);
private static final SimpleDateFormat MDYFormat = new SimpleDateFormat("MM/dd/yyyy");
+ private static final String KEY_SUCCESS = "Success";
+
private static final DefaultActionResolver _actionResolver = new DefaultActionResolver(TestResultsController.class);
// Tab name constants for menu highlighting
@@ -160,6 +170,11 @@ public static String getTabClass(String tabName, String activeTab)
}
}
+ private static Date parseDate(String dateStr) throws ParseException
+ {
+ return StringUtils.isNotBlank(dateStr) ? MDYFormat.parse(dateStr) : null;
+ }
+
// Form class for RetrainAllAction
public static class RetrainAllForm
{
@@ -200,27 +215,76 @@ public TestResultsController()
* action to view rundown.jsp and also the landing page for module
*/
@RequiresPermission(ReadPermission.class)
- public static class BeginAction extends SimpleViewAction
+ public static class BeginAction extends SimpleViewAction
{
@Override
- public ModelAndView getView(Object o, BindException errors) throws Exception
+ public ModelAndView getView(RunDownForm form, BindException errors) throws Exception
{
- RunDownBean bean = getRunDownBean(getUser(), getContainer(), getViewContext());
- return new JspView<>("/org/labkey/testresults/view/rundown.jsp", bean);
+ Date endDate;
+ try { endDate = form.getEndDate(); }
+ catch (ParseException e)
+ {
+ errors.reject(ERROR_MSG, "Invalid date format: " + form.getEnd() + " (expected MM/dd/yyyy)");
+ return new SimpleErrorView(errors);
+ }
+
+ RunDownBean bean = getRunDownBean(getUser(), getContainer(), endDate, form.getViewType());
+ JspView view = new JspView<>("/org/labkey/testresults/view/rundown.jsp", bean);
+ view.setFrame(WebPartView.FrameType.PORTAL);
+ return view;
}
@Override
- public void addNavTrail(NavTree root) { }
+ public void addNavTrail(NavTree root)
+ {
+ addModuleNavTrail(root, getContainer());
+ }
+ }
+
+ private static void addModuleNavTrail(NavTree root, Container container)
+ {
+ root.addChild("Test Results", new ActionURL(TestResultsController.BeginAction.class, container));
+ }
+
+
+ public static class RunDownForm
+ {
+ private String _end;
+ private String _viewType;
+
+ public String getEnd()
+ {
+ return _end;
+ }
+ public void setEnd(String end)
+ {
+ _end = end;
+ }
+ public Date getEndDate() throws ParseException
+ {
+ return parseDate(_end);
+ }
+ public String getViewType()
+ {
+ return _viewType;
+ }
+ public void setViewType(String viewType)
+ {
+ _viewType = viewType;
+ }
+ }
+
+ public static RunDownBean getRunDownBean(org.labkey.api.security.User user, Container c) throws ParseException, IOException
+ {
+ return getRunDownBean(user, c, null, null);
}
// return TestDataBean specifically for rundown.jsp aka the home page of the module
- public static RunDownBean getRunDownBean(org.labkey.api.security.User user, Container c, ViewContext viewContext) throws ParseException, IOException
+ public static RunDownBean getRunDownBean(org.labkey.api.security.User user, Container c, Date endDateParam, String viewType) throws ParseException, IOException
{
- String end = viewContext.getRequest().getParameter("end");
- String viewType = viewContext.getRequest().getParameter("viewType");
Calendar cal = Calendar.getInstance();
- cal.setTime(end != null && !end.isEmpty() ? MDYFormat.parse(end) : new Date());
+ cal.setTime(endDateParam != null ? endDateParam : new Date());
setToEightAM(cal);
Date endDate = cal.getTime();
cal.add(Calendar.DATE, -1);
@@ -244,7 +308,7 @@ public static RunDownBean getRunDownBean(org.labkey.api.security.User user, Cont
// show blank page if no runs exist
if (todaysRuns.isEmpty() && monthRuns.isEmpty())
- return new RunDownBean(new RunDetail[0], new User[0]);
+ return new RunDownBean(new RunDetail[0], new User[0], viewType, null, endDate);
RunDetail[] today = todaysRuns.toArray(new RunDetail[0]);
if (!todaysRuns.isEmpty())
@@ -412,39 +476,38 @@ public ModelAndView getView(Object o, BindException errors) throws Exception
User[] users = getUsers(getContainer(), null);
TestsDataBean bean = new TestsDataBean(runs, users);
- return new JspView<>("/org/labkey/testresults/view/trainingdata.jsp", bean);
+ JspView view = new JspView<>("/org/labkey/testresults/view/trainingdata.jsp", bean);
+ view.setFrame(WebPartView.FrameType.PORTAL);
+ return view;
}
@Override
- public void addNavTrail(NavTree root) { }
+ public void addNavTrail(NavTree root)
+ {
+ addModuleNavTrail(root, getContainer());
+ }
}
// API endpoint for adding or removing a run for the training set needs parameters: runId=int&train=boolean
@RequiresPermission(AdminPermission.class)
- public static class TrainRunAction extends MutatingApiAction {
+ public static class TrainRunAction extends MutatingApiAction {
@Override
- public Object execute(Object o, BindException errors)
+ public Object execute(TrainRunForm form, BindException errors)
{
- var req = getViewContext().getRequest();
- int runId = Integer.parseInt(req.getParameter("runId"));
- String trainString = req.getParameter("train");
- boolean train = false;
- boolean force = false;
- if (trainString.equalsIgnoreCase("true"))
- {
- train = true;
- }
- else if (trainString.equalsIgnoreCase("false"))
- {
- }
- else if (trainString.equalsIgnoreCase("force"))
+ if (form.getRunId() == null)
{
- force = true;
+ return new ApiSimpleResponse(Map.of(KEY_SUCCESS, false, "error", "runId is required"));
}
- else
+ int runId = form.getRunId();
+ String trainString = form.getTrain();
+ if (!Strings.CI.equals(trainString, "true") &&
+ !Strings.CI.equals(trainString, "false") &&
+ !Strings.CI.equals(trainString, "force"))
{
- return new ApiSimpleResponse("Success", false); // invalid train value
+ return new ApiSimpleResponse(Map.of(KEY_SUCCESS, false, "error", "train must be one of: true, false, force"));
}
+ boolean train = Strings.CI.equals(trainString, "true"); // true = add to training set, false = remove
+ boolean force = Strings.CI.equals(trainString, "force");
SQLFragment sqlFragment = new SQLFragment();
sqlFragment.append("SELECT * FROM " + TestResultsSchema.getTableInfoTrain() + " WHERE runid = ?");
@@ -458,9 +521,9 @@ else if (trainString.equalsIgnoreCase("force"))
if (!force)
{
if (details.length == 0)
- return new ApiSimpleResponse("Success", false); // run does not exist
+ return new ApiSimpleResponse(Map.of(KEY_SUCCESS, false, "error", "run does not exist: " + runId));
else if ((train && !foundRuns.isEmpty()) || (!train && foundRuns.isEmpty()))
- return new ApiSimpleResponse("Success", false); // no action necessary
+ return new ApiSimpleResponse(Map.of(KEY_SUCCESS, false, "error", "no action necessary"));
}
DbScope scope = TestResultsSchema.getSchema().getScope();
try (DbScope.Transaction transaction = scope.ensureTransaction())
@@ -497,34 +560,67 @@ else if ((train && !foundRuns.isEmpty()) || (!train && foundRuns.isEmpty()))
new SqlExecutor(scope).execute(sqlFragmentUpdate);
transaction.commit();
}
- return new ApiSimpleResponse("Success", true);
+ return new ApiSimpleResponse(KEY_SUCCESS, true);
+ }
+ }
+
+ public static class TrainRunForm
+ {
+ private Integer _runId;
+ private String _train;
+
+ public Integer getRunId()
+ {
+ return _runId;
+ }
+ public void setRunId(Integer runId)
+ {
+ _runId = runId;
+ }
+ public String getTrain()
+ {
+ return _train;
+ }
+ public void setTrain(String train)
+ {
+ _train = train;
}
}
/**
* action to view user.jsp and all run details for user in date selection
- * accepts a url parameter "user" which will be the user that the jsp displays runs for
+ * accepts a url parameter "username" which will be the user that the jsp displays runs for
* accepts url parameter "start" and "end" which will be the date range of selected runs for that user to display
*/
@RequiresPermission(ReadPermission.class)
- public static class ShowUserAction extends SimpleViewAction
+ public static class ShowUserAction extends SimpleViewAction
{
@Override
- public ModelAndView getView(Object o, BindException errors) throws Exception
+ public ModelAndView getView(ShowUserForm form, BindException errors) throws Exception
{
- HttpServletRequest req = getViewContext().getRequest();
- String start = req.getParameter("start");
- String end = req.getParameter("end");
- String userName = req.getParameter("user");
- String dataInclude = req.getParameter("datainclude");
- Date startDate = start == null
- ? DateUtils.addDays(new Date(), -6) // DEFAULT TO LAST WEEK's RUNS
- : MDYFormat.parse(start);
- Date endDate = end == null
- ? new Date()
- : DateUtils.addMilliseconds(DateUtils.ceiling(MDYFormat.parse(end), Calendar.DATE), 0);
+ String userName = form.getUsername();
+ String dataInclude = form.getDatainclude();
+ Date startDate;
+ Date endDate;
+ try { startDate = form.getStartDate(); }
+ catch (ParseException e)
+ {
+ errors.reject(ERROR_MSG, "Invalid start date format: " + form.getStart() + " (expected MM/dd/yyyy)");
+ return new SimpleErrorView(errors);
+ }
+ try { endDate = form.getEndDate(); }
+ catch (ParseException e)
+ {
+ errors.reject(ERROR_MSG, "Invalid end date format: " + form.getEnd() + " (expected MM/dd/yyyy)");
+ return new SimpleErrorView(errors);
+ }
+ if (startDate == null)
+ startDate = DateUtils.addDays(new Date(), -6); // DEFAULT TO LAST WEEK's RUNS
+ endDate = endDate == null
+ ? new Date()
+ : DateUtils.addMilliseconds(DateUtils.ceiling(endDate, Calendar.DATE), 0);
User user = null;
- if (userName != null && !userName.isEmpty())
+ if (StringUtils.isNotBlank(userName))
{
User[] users = getUsers(getContainer(), userName);
if (users.length == 1)
@@ -537,12 +633,68 @@ public ModelAndView getView(Object o, BindException errors) throws Exception
ensureRunDataCached(runs, false);
TestsDataBean bean = new TestsDataBean(runs, user == null ? new User[0] : new User[]{user});
- return new JspView<>("/org/labkey/testresults/view/user.jsp", bean);
+ bean.setStartDate(startDate);
+ bean.setEndDate(endDate);
+ bean.setUsername(userName);
+ bean.setDataInclude(dataInclude);
+ JspView view = new JspView<>("/org/labkey/testresults/view/user.jsp", bean);
+ view.setFrame(WebPartView.FrameType.PORTAL);
+ return view;
}
@Override
public void addNavTrail(NavTree root)
{
+ addModuleNavTrail(root, getContainer());
+ }
+ }
+
+ public static class ShowUserForm
+ {
+ private String _start;
+ private String _end;
+ private String _username;
+ private String _datainclude;
+
+ public String getStart()
+ {
+ return _start;
+ }
+ public void setStart(String start)
+ {
+ _start = start;
+ }
+ public Date getStartDate() throws ParseException
+ {
+ return parseDate(_start);
+ }
+ public String getEnd()
+ {
+ return _end;
+ }
+ public void setEnd(String end)
+ {
+ _end = end;
+ }
+ public Date getEndDate() throws ParseException
+ {
+ return parseDate(_end);
+ }
+ public String getUsername()
+ {
+ return _username;
+ }
+ public void setUsername(String username)
+ {
+ _username = username;
+ }
+ public String getDatainclude()
+ {
+ return _datainclude;
+ }
+ public void setDatainclude(String datainclude)
+ {
+ _datainclude = datainclude;
}
}
@@ -551,19 +703,20 @@ public void addNavTrail(NavTree root)
* accepts a url parameter "runId" which will be the run that the jsp displays the information of
*/
@RequiresPermission(ReadPermission.class)
- public static class ShowRunAction extends SimpleViewAction
+ public static class ShowRunAction extends SimpleViewAction
{
@Override
- public ModelAndView getView(Object o, BindException errors) throws Exception
+ public ModelAndView getView(ShowRunForm form, BindException errors) throws Exception
{
- int runId;
- try
+ if (form.getRunId() == null)
{
- runId = Integer.parseInt(getViewContext().getRequest().getParameter("runId"));
- } catch (Exception e) {
- return new JspView<>("/org/labkey/testresults/view/runDetail.jsp", null);
+ // Null bean causes runDetail.jsp to display a form prompting the user to enter a run ID
+ JspView errorView = new JspView<>("/org/labkey/testresults/view/runDetail.jsp", null);
+ errorView.setFrame(WebPartView.FrameType.PORTAL);
+ return errorView;
}
- String filterTestPassesBy = getViewContext().getRequest().getParameter("filter");
+ int runId = form.getRunId();
+ String filterTestPassesBy = form.getFilter();
SimpleFilter filter = new SimpleFilter();
filter.addCondition(FieldKey.fromParts("testrunid"), runId);
@@ -583,10 +736,18 @@ public ModelAndView getView(Object o, BindException errors) throws Exception
RunDetail[] runs = executeGetRunsSQLFragment(sqlFragment, getContainer(), false, true);
if (runs.length == 0)
- return new JspView<>("/org/labkey/testresults/view/runDetail.jsp", null);
+ {
+ JspView errorView = new JspView<>("/org/labkey/testresults/view/runDetail.jsp", null);
+ errorView.setFrame(WebPartView.FrameType.PORTAL);
+ return errorView;
+ }
RunDetail run = runs[0];
if (run == null)
- return new JspView<>("/org/labkey/testresults/view/runDetail.jsp", null);
+ {
+ JspView errorView = new JspView<>("/org/labkey/testresults/view/runDetail.jsp", null);
+ errorView.setFrame(WebPartView.FrameType.PORTAL);
+ return errorView;
+ }
if (filterTestPassesBy != null) {
if (filterTestPassesBy.equals("duration")) {
List filteredPasses = Arrays.asList(passes);
@@ -611,12 +772,38 @@ public ModelAndView getView(Object o, BindException errors) throws Exception
run.setHang(hangs[0]);
run.setPasses(passes);
TestsDataBean bean = new TestsDataBean(runs, new User[0]);
- return new JspView<>("/org/labkey/testresults/view/runDetail.jsp", bean);
+ JspView view = new JspView<>("/org/labkey/testresults/view/runDetail.jsp", bean);
+ view.setFrame(WebPartView.FrameType.PORTAL);
+ return view;
}
@Override
public void addNavTrail(NavTree root)
{
+ addModuleNavTrail(root, getContainer());
+ }
+ }
+
+ public static class ShowRunForm
+ {
+ private Integer _runId;
+ private String _filter;
+
+ public Integer getRunId()
+ {
+ return _runId;
+ }
+ public void setRunId(Integer runId)
+ {
+ _runId = runId;
+ }
+ public String getFilter()
+ {
+ return _filter;
+ }
+ public void setFilter(String filter)
+ {
+ _filter = filter;
}
}
@@ -625,12 +812,12 @@ public void addNavTrail(NavTree root)
* accepts a url parameter "viewType" of either wk(week), mo(month), or yr(year) and defaults to month
*/
@RequiresPermission(ReadPermission.class)
- public static class LongTermAction extends SimpleViewAction
+ public static class LongTermAction extends SimpleViewAction
{
@Override
- public ModelAndView getView(Object o, BindException errors) throws Exception
+ public ModelAndView getView(LongTermForm form, BindException errors) throws Exception
{
- String viewType = getViewContext().getRequest().getParameter("viewType");
+ String viewType = form.getViewType();
LongTermBean bean = new LongTermBean(new RunDetail[0], new User[0]); // bean that will be handed to jsp
viewType = getViewType(viewType, ViewType.YEAR);
@@ -646,12 +833,29 @@ public ModelAndView getView(Object o, BindException errors) throws Exception
bean.setNonAssociatedFailures(failures);
ensureRunDataCached(runs, true);
- return new JspView<>("/org/labkey/testresults/view/longTerm.jsp", bean);
+ JspView view = new JspView<>("/org/labkey/testresults/view/longTerm.jsp", bean);
+ view.setFrame(WebPartView.FrameType.PORTAL);
+ return view;
}
@Override
public void addNavTrail(NavTree root)
{
+ addModuleNavTrail(root, getContainer());
+ }
+ }
+
+ public static class LongTermForm
+ {
+ private String _viewType;
+
+ public String getViewType()
+ {
+ return _viewType;
+ }
+ public void setViewType(String viewType)
+ {
+ _viewType = viewType;
}
}
@@ -661,17 +865,25 @@ public void addNavTrail(NavTree root)
* accepts parameter viewType as 'wk', 'mo', or 'yr'. defaults to 'day'
*/
@RequiresPermission(ReadPermission.class)
- public static class ShowFailures extends SimpleViewAction
+ public static class ShowFailures extends SimpleViewAction
{
@Override
- public ModelAndView getView(Object o, BindException errors) throws Exception
+ public ModelAndView getView(ShowFailuresForm form, BindException errors) throws Exception
{
- HttpServletRequest req = getViewContext().getRequest();
- String end = req.getParameter("end");
- String failedTest = req.getParameter("failedTest");
- String viewType = getViewType(req.getParameter("viewType"), ViewType.DAY);
+ String failedTest = form.getFailedTest();
+ String viewType = getViewType(form.getViewType(), ViewType.DAY);
- Date endDate = setToEightAM(!StringUtils.isEmpty(end) ? MDYFormat.parse(end) : new Date());
+ Date endParsed;
+ try
+ {
+ endParsed = form.getEndDate();
+ }
+ catch (ParseException e)
+ {
+ errors.reject(ERROR_MSG, "Invalid date format: " + form.getEnd() + " (expected MM/dd/yyyy)");
+ return new SimpleErrorView(errors);
+ }
+ Date endDate = setToEightAM(endParsed != null ? endParsed : new Date());
Date startDate = getStartDate(viewType, ViewType.DAY, endDate); // defaults to day
RunDetail[] runs = getRunsSinceDate(startDate, endDate, getContainer(), null, false, false);
@@ -693,16 +905,57 @@ public ModelAndView getView(Object o, BindException errors) throws Exception
(run.getLeaks() != null && Arrays.stream(run.getLeaks()).anyMatch(leak -> leak.getTestName().equals(failedTest)))
).toArray(RunDetail[]::new));
- return new JspView<>("/org/labkey/testresults/view/failureDetail.jsp", bean);
+ JspView view = new JspView<>("/org/labkey/testresults/view/failureDetail.jsp", bean);
+ view.setFrame(WebPartView.FrameType.PORTAL);
+ return view;
}
bean.setRuns(runs);
- return new JspView<>("/org/labkey/testresults/view/multiFailureDetail.jsp", bean);
+ JspView view = new JspView<>("/org/labkey/testresults/view/multiFailureDetail.jsp", bean);
+ view.setFrame(WebPartView.FrameType.PORTAL);
+ return view;
}
@Override
public void addNavTrail(NavTree root)
{
+ addModuleNavTrail(root, getContainer());
+ }
+ }
+
+ public static class ShowFailuresForm
+ {
+ private String _end;
+ private String _failedTest;
+ private String _viewType;
+
+ public String getEnd()
+ {
+ return _end;
+ }
+ public void setEnd(String end)
+ {
+ _end = end;
+ }
+ public Date getEndDate() throws ParseException
+ {
+ return parseDate(_end);
+ }
+ public String getFailedTest()
+ {
+ return _failedTest;
+ }
+ public void setFailedTest(String failedTest)
+ {
+ _failedTest = failedTest;
+ }
+ public String getViewType()
+ {
+ return _viewType;
+ }
+ public void setViewType(String viewType)
+ {
+ _viewType = viewType;
}
}
@@ -710,13 +963,19 @@ public void addNavTrail(NavTree root)
* action for deleting a run ex:'deleteRun.view?runId=x'
*/
@RequiresPermission(AdminPermission.class)
- public static class DeleteRunAction extends MutatingApiAction {
+ public static class DeleteRunAction extends MutatingApiAction {
@Override
- public Object execute(Object o, BindException errors)
+ public Object execute(RunIdForm form, BindException errors)
{
ApiSimpleResponse response = new ApiSimpleResponse();
+ if (form.getRunId() == null)
+ {
+ response.put(KEY_SUCCESS, false);
+ response.put("error", "runId is required");
+ return response;
+ }
- int rowId = Integer.parseInt(getViewContext().getRequest().getParameter("runId"));
+ int rowId = form.getRunId();
SimpleFilter filter = new SimpleFilter();
filter.addCondition(FieldKey.fromParts("testrunid"), rowId);
try (DbScope.Transaction transaction = TestResultsSchema.getSchema().getScope().ensureTransaction()) {
@@ -727,45 +986,93 @@ public Object execute(Object o, BindException errors)
Table.delete(TestResultsSchema.getTableInfoTestRuns(), rowId); // delete run last because of foreign key
transaction.commit();
} catch (Exception x) {
- response.put("success", false);
+ response.put(KEY_SUCCESS, false);
response.put("error", x.getMessage());
return response;
}
- response.put("success", true);
+ response.put(KEY_SUCCESS, true);
return response;
}
}
+ public static class RunIdForm
+ {
+ private Integer _runId;
+
+ public Integer getRunId()
+ {
+ return _runId;
+ }
+ public void setRunId(Integer runId)
+ {
+ _runId = runId;
+ }
+ }
+
@RequiresPermission(AdminPermission.class)
- public static class FlagRunAction extends MutatingApiAction {
+ public static class FlagRunAction extends MutatingApiAction {
@Override
- public Object execute(Object o, BindException errors)
+ public Object execute(FlagRunForm form, BindException errors)
{
ApiSimpleResponse response = new ApiSimpleResponse();
+ if (form.getRunId() == null)
+ {
+ response.put(KEY_SUCCESS, false);
+ response.put("error", "runId is required");
+ return response;
+ }
- int rowId = Integer.parseInt(getViewContext().getRequest().getParameter("runId"));
- boolean flag = Boolean.parseBoolean(getViewContext().getRequest().getParameter("flag"));
+ int rowId = form.getRunId();
+ boolean flag = form.getFlag() != null ? form.getFlag() : false;
SimpleFilter filter = new SimpleFilter();
filter.addCondition(FieldKey.fromParts("id"), rowId);
try (DbScope.Transaction transaction = TestResultsSchema.getSchema().getScope().ensureTransaction()) {
- RunDetail[] details = new TableSelector(TestResultsSchema.getTableInfoTestRuns(), filter, null).getArray(RunDetail.class);
- RunDetail detail = details[0];
- if (getViewContext().getRequest().getParameter("flag") == null) // if not specified keep same
+ RunDetail detail = new TableSelector(TestResultsSchema.getTableInfoTestRuns(), filter, null).getObject(RunDetail.class);
+ if (detail == null)
+ {
+ response.put(KEY_SUCCESS, false);
+ response.put("error", "run not found: " + rowId);
+ return response;
+ }
+ if (form.getFlag() == null) // if not specified keep same
flag = detail.isFlagged();
detail.setFlagged(flag);
Table.update(null, TestResultsSchema.getTableInfoTestRuns(), detail, detail.getId());
transaction.commit();
} catch (Exception x) {
- response.put("success", false);
+ response.put(KEY_SUCCESS, false);
response.put("error", x.getMessage());
return response;
}
- response.put("success", true);
+ response.put(KEY_SUCCESS, true);
return response;
}
}
+ public static class FlagRunForm
+ {
+ private Integer _runId;
+ private Boolean _flag;
+
+ public Integer getRunId()
+ {
+ return _runId;
+ }
+ public void setRunId(Integer runId)
+ {
+ _runId = runId;
+ }
+ public Boolean getFlag()
+ {
+ return _flag;
+ }
+ public void setFlag(Boolean flag)
+ {
+ _flag = flag;
+ }
+ }
+
/**
* action to show all flagged runs flagged.jsp
*/
@@ -778,34 +1085,35 @@ public ModelAndView getView(Object o, BindException errors) throws Exception
SimpleFilter filter = new SimpleFilter();
filter.addCondition(FieldKey.fromParts("flagged"), true);
RunDetail[] details = new TableSelector(TestResultsSchema.getTableInfoTestRuns(), filter, null).getArray(RunDetail.class);
- return new JspView<>("/org/labkey/testresults/view/flagged.jsp", new TestsDataBean(details, new User[0]));
+ JspView view = new JspView<>("/org/labkey/testresults/view/flagged.jsp", new TestsDataBean(details, new User[0]));
+ view.setFrame(WebPartView.FrameType.PORTAL);
+ return view;
}
@Override
public void addNavTrail(NavTree root)
{
+ addModuleNavTrail(root, getContainer());
}
}
@RequiresSiteAdmin
- public static class ChangeBoundaries extends MutatingApiAction
+ public static class ChangeBoundaries extends MutatingApiAction
{
@Override
- public Object execute(Object o, BindException errors) throws Exception
+ public Object execute(BoundariesForm form, BindException errors) throws Exception
{
//error handling - must be numbers, and limits on the range
Map res = new HashMap<>();
- String warningBoundary = getViewContext().getRequest().getParameter("warningb");
- String errorBoundary = getViewContext().getRequest().getParameter("errorb");
+ Integer warningB = form.getWarningb();
+ Integer errorB = form.getErrorb();
- int warningB;
- int errorB;
-
- try {
- warningB = Integer.parseInt(warningBoundary);
- errorB = Integer.parseInt(errorBoundary);
- } catch (NumberFormatException nfe) {
- res.put("Message", "fail: you need to input a number");
+ if (warningB == null) {
+ res.put("Message", "fail: warning boundary must be a number");
+ return new ApiSimpleResponse(res);
+ }
+ if (errorB == null) {
+ res.put("Message", "fail: error boundary must be a number");
return new ApiSimpleResponse(res);
}
@@ -842,18 +1150,38 @@ public Object execute(Object o, BindException errors) throws Exception
}
}
+ public static class BoundariesForm
+ {
+ private Integer _warningb;
+ private Integer _errorb;
+
+ public Integer getWarningb()
+ {
+ return _warningb;
+ }
+ public void setWarningb(Integer warningb)
+ {
+ _warningb = warningb;
+ }
+ public Integer getErrorb()
+ {
+ return _errorb;
+ }
+ public void setErrorb(Integer errorb)
+ {
+ _errorb = errorb;
+ }
+ }
+
@RequiresPermission(ReadPermission.class)
- public static class ViewLogAction extends ReadOnlyApiAction
+ public static class ViewLogAction extends ReadOnlyApiAction
{
@Override
- public Object execute(Object o, BindException errors)
+ public Object execute(RunIdForm form, BindException errors)
{
- int runId;
- try {
- runId = Integer.parseInt(getViewContext().getRequest().getParameter("runid"));
- } catch (Exception e) {
+ if (form.getRunId() == null)
return new ApiSimpleResponse("log", null);
- }
+ int runId = form.getRunId();
SQLFragment sqlFragment = new SQLFragment();
sqlFragment.append("SELECT log FROM testresults.testruns WHERE id = ?");
sqlFragment.add(runId);
@@ -867,17 +1195,14 @@ public Object execute(Object o, BindException errors)
}
@RequiresPermission(ReadPermission.class)
- public static class ViewXmlAction extends ReadOnlyApiAction
+ public static class ViewXmlAction extends ReadOnlyApiAction
{
@Override
- public Object execute(Object o, BindException errors)
+ public Object execute(RunIdForm form, BindException errors)
{
- int runId;
- try {
- runId = Integer.parseInt(getViewContext().getRequest().getParameter("runid"));
- } catch (Exception e) {
+ if (form.getRunId() == null)
return new ApiSimpleResponse("xml", null);
- }
+ int runId = form.getRunId();
SQLFragment sqlFragment = new SQLFragment();
sqlFragment.append("SELECT xml FROM testresults.testruns WHERE id = ?");
sqlFragment.add(runId);
@@ -890,11 +1215,11 @@ public Object execute(Object o, BindException errors)
}
}
- @RequiresNoPermission
- public static class SendEmailNotificationAction extends ReadOnlyApiAction
+ @RequiresPermission(AdminOperationsPermission.class)
+ public static class SendEmailNotificationAction extends ReadOnlyApiAction
{
@Override
- public Object execute(Object o, BindException errors)
+ public Object execute(SendEmailForm form, BindException errors)
{
org.labkey.api.security.User from;
try
@@ -905,14 +1230,13 @@ public Object execute(Object o, BindException errors)
{
return new ApiSimpleResponse("error", e.getMessage());
}
- HttpServletRequest req = getViewContext().getRequest();
- String to = req.getParameter("to");
+ String to = form.getTo();
if (to == null || to.isEmpty())
to = SendTestResultsEmail.DEFAULT_EMAIL.RECIPIENT;
- String subject = req.getParameter("subject");
+ String subject = form.getSubject();
if (subject == null)
subject = "";
- String message = req.getParameter("message");
+ String message = form.getMessage();
if (message == null)
message = "";
List recipients = Collections.singletonList(to);
@@ -924,17 +1248,49 @@ public Object execute(Object o, BindException errors)
}
}
+ public static class SendEmailForm
+ {
+ private String _to;
+ private String _subject;
+ private String _message;
+
+ public String getTo()
+ {
+ return _to;
+ }
+ public void setTo(String to)
+ {
+ _to = to;
+ }
+ public String getSubject()
+ {
+ return _subject;
+ }
+ public void setSubject(String subject)
+ {
+ _subject = subject;
+ }
+ public String getMessage()
+ {
+ return _message;
+ }
+ public void setMessage(String message)
+ {
+ _message = message;
+ }
+ }
+
@RequiresSiteAdmin
- public static class SetEmailCronAction extends MutatingApiAction {
+ public static class SetEmailCronAction extends MutatingApiAction {
// NOTE: user needs read permissions on development folder
@Override
- public Object execute(Object o, BindException errors) throws Exception
+ public Object execute(EmailCronForm form, BindException errors) throws Exception
{
- String action = getViewContext().getRequest().getParameter("action");
- String emailFrom = getViewContext().getRequest().getParameter("emailF");
- String emailTo = getViewContext().getRequest().getParameter("emailT");
+ String action = form.getAction();
+ String emailFrom = form.getEmailF();
+ String emailTo = form.getEmailT();
Scheduler scheduler = new StdSchedulerFactory().getScheduler();
JobKey jobKeyEmail = new JobKey(JOB_NAME, JOB_GROUP);
Map res = new HashMap<>();
@@ -979,7 +1335,7 @@ public Object execute(Object o, BindException errors) throws Exception
}
break;
case SendTestResultsEmail.TEST_GET_HTML_EMAIL:
- SendTestResultsEmail testHtml = new SendTestResultsEmail(getGenerateDate());
+ SendTestResultsEmail testHtml = new SendTestResultsEmail(form.getGenerateDate());
Pair msg = testHtml.getHTMLEmail(getViewContext().getUser());
res.put("subject", msg.first);
res.put("HTML", msg.second);
@@ -987,14 +1343,14 @@ public Object execute(Object o, BindException errors) throws Exception
break;
case SendTestResultsEmail.TEST_ADMIN:
// test target send email immedately and only to Yuval
- SendTestResultsEmail testAdmin = new SendTestResultsEmail(getGenerateDate());
+ SendTestResultsEmail testAdmin = new SendTestResultsEmail(form.getGenerateDate());
ValidEmail admin = new ValidEmail(SendTestResultsEmail.DEFAULT_EMAIL.ADMIN_EMAIL);
testAdmin.execute(SendTestResultsEmail.TEST_ADMIN, UserManager.getUser(admin), SendTestResultsEmail.DEFAULT_EMAIL.ADMIN_EMAIL);
res.put("Message", "Testing testing 123");
res.put("Response", "true");
break;
case SendTestResultsEmail.TEST_CUSTOM:
- SendTestResultsEmail testCustom = new SendTestResultsEmail(getGenerateDate());
+ SendTestResultsEmail testCustom = new SendTestResultsEmail(form.getGenerateDate());
String error = "";
ValidEmail from = null;
ValidEmail to = null;
@@ -1043,32 +1399,83 @@ public static Scheduler start(Scheduler scheduler, JobKey jobKeyEmail) throws Sc
return scheduler;
}
- private Date getGenerateDate() {
- String s = getViewContext().getRequest().getParameter("generatedate");
- Date d = null;
- if (s != null && !s.isEmpty()) {
- try {
- d = MDYFormat.parse(s);
- } catch (ParseException e) {
- }
+ }
+
+ public static class EmailCronForm
+ {
+ private String _action;
+ private String _emailF;
+ private String _emailT;
+ private String _generatedate;
+
+ public String getAction()
+ {
+ return _action;
+ }
+ public void setAction(String action)
+ {
+ _action = action;
+ }
+ public String getEmailF()
+ {
+ return _emailF;
+ }
+ public void setEmailF(String emailF)
+ {
+ _emailF = emailF;
+ }
+ public String getEmailT()
+ {
+ return _emailT;
+ }
+ public void setEmailT(String emailT)
+ {
+ _emailT = emailT;
+ }
+ public String getGeneratedate()
+ {
+ return _generatedate;
+ }
+ public void setGeneratedate(String generatedate)
+ {
+ _generatedate = generatedate;
+ }
+
+ public Date getGenerateDate()
+ {
+ try
+ {
+ return parseDate(_generatedate);
+ }
+ catch (ParseException e)
+ {
+ return null;
}
- return d;
}
}
@RequiresSiteAdmin
- public static class SetUserActive extends MutatingApiAction
+ public static class SetUserActive extends MutatingApiAction
{
@Override
- public Object execute(Object o, BindException errors)
+ public Object execute(SetUserActiveForm form, BindException errors)
{
Map res = new HashMap<>();
- String active = getViewContext().getRequest().getParameter("active");
- String userId = getViewContext().getRequest().getParameter("userId");
- boolean isActive = Boolean.parseBoolean(active);
+ if (form.getUserId() == null)
+ {
+ res.put("Message", "userId is required");
+ return new ApiSimpleResponse(res);
+ }
+ if (form.isActive() == null)
+ {
+ res.put("Message", "active parameter is required (true to activate, false to deactivate)");
+ return new ApiSimpleResponse(res);
+ }
+ boolean isActive = form.isActive();
+ int userId = form.getUserId();
SimpleFilter filter = new SimpleFilter();
- filter.addCondition(FieldKey.fromParts("userid"), Integer.parseInt(userId));
+ filter.addCondition(FieldKey.fromParts("userid"), userId);
filter.addCondition(FieldKey.fromParts("container"), getContainer());
User[] users = new TableSelector(TestResultsSchema.getTableInfoUserData(), filter, null).getArray(User.class);
if (users.length == 0) {
@@ -1093,6 +1500,29 @@ public Object execute(Object o, BindException errors)
}
}
+ public static class SetUserActiveForm
+ {
+ private Boolean _active;
+ private Integer _userId;
+
+ public Boolean isActive()
+ {
+ return _active;
+ }
+ public void setActive(Boolean active)
+ {
+ _active = active;
+ }
+ public Integer getUserId()
+ {
+ return _userId;
+ }
+ public void setUserId(Integer userId)
+ {
+ _userId = userId;
+ }
+ }
+
@RequiresSiteAdmin
public static class RetrainAllAction extends MutatingApiAction
{
@@ -1180,7 +1610,7 @@ public Object execute(RetrainAllForm form, BindException errors)
transaction.commit();
ApiSimpleResponse response = new ApiSimpleResponse();
- response.put("success", true);
+ response.put(KEY_SUCCESS, true);
response.put("usersRetrained", usersRetrained);
response.put("totalTrainRuns", totalTrainRuns);
response.put("mode", form.getMode());
@@ -1190,7 +1620,7 @@ public Object execute(RetrainAllForm form, BindException errors)
{
_log.error("Error in RetrainAllAction", e);
ApiSimpleResponse response = new ApiSimpleResponse();
- response.put("success", false);
+ response.put(KEY_SUCCESS, false);
response.put("error", e.getMessage());
return response;
}
@@ -1243,13 +1673,13 @@ else if (xml.isEmpty())
} catch (Exception e) {
_log.info("XML failed to parse/store");
_log.info("Attempting to save file for a future post attempt");
- res.put("Success", false);
+ res.put(KEY_SUCCESS, false);
res.put("Message", "Error Parsing XML attempting to save the XML file... " + NIGHTLY_POSTER.SaveXML(file, getContainer()));
res.put("Exception", e + NIGHTLY_POSTER.getStackTraceText(e));
return new ApiSimpleResponse(res);
}
- return new ApiSimpleResponse("Success", true);
+ return new ApiSimpleResponse(KEY_SUCCESS, true);
}
private void DebugRequest(HttpServletRequest hsRequest)
@@ -1283,12 +1713,15 @@ public ModelAndView getView(Object o, BindException errors)
File[] files = local.listFiles();
if (files == null)
files = new File[0];
- return new JspView<>("/org/labkey/testresults/view/errorFiles.jsp", files);
+ JspView view = new JspView<>("/org/labkey/testresults/view/errorFiles.jsp", files);
+ view.setFrame(WebPartView.FrameType.PORTAL);
+ return view;
}
@Override
public void addNavTrail(NavTree root)
{
+ addModuleNavTrail(root, getContainer());
}
}
@@ -1485,7 +1918,7 @@ private static void ParseAndStoreXML(String xml, Container c) throws Exception
handleLeaks.add(new TestHandleLeakDetail(0, elLeak.getAttribute("name"), type, Float.parseFloat(elLeak.getAttribute("handles"))));
} else {
_log.error("Error parsing Leak " + elLeak.getAttribute("name") + ".");
- throw new XMLParseException();
+ throw new IllegalArgumentException("Leak element missing both 'bytes' and 'handles' attributes: " + elLeak.getAttribute("name"));
}
}
@@ -1537,9 +1970,9 @@ private static void ParseAndStoreXML(String xml, Container c) throws Exception
Double.parseDouble(test.getAttribute("managed")),
Double.parseDouble(test.getAttribute("total")),
// New leak tracking values
- StringUtils.hasText(committedAttr) ? Double.parseDouble(committedAttr) : 0,
- StringUtils.hasText(usergdiAttr) ? Integer.parseInt(usergdiAttr) : 0,
- StringUtils.hasText(handlesAttr) ? Integer.parseInt(handlesAttr) : 0,
+ StringUtils.isNotBlank(committedAttr) ? Double.parseDouble(committedAttr) : 0,
+ StringUtils.isNotBlank(usergdiAttr) ? Integer.parseInt(usergdiAttr) : 0,
+ StringUtils.isNotBlank(handlesAttr) ? Integer.parseInt(handlesAttr) : 0,
timestamp);
avgMemory += pass.getTotalMemory();
passes.add(pass);
@@ -1581,8 +2014,15 @@ private static void ParseAndStoreXML(String xml, Container c) throws Exception
}
byte[] pointSummary = encodeRunPassSummary(passes.toArray(new TestPassDetail[0]));
- // Compress xml, will be stored in testresults.testruns, column xml
- byte[] compressedXML = xml != null ? compressString(docElement.toString()) : null;
+ // Serialize the DOM (with removed) and compress for storage
+ byte[] compressedXML = null;
+ if (xml != null)
+ {
+ Transformer transformer = TransformerFactory.newInstance().newTransformer();
+ StringWriter xmlWriter = new StringWriter();
+ transformer.transform(new DOMSource(docElement), new StreamResult(xmlWriter));
+ compressedXML = compressString(xmlWriter.toString());
+ }
byte[] compressedLog = log != null ? compressString(log) : null;
RunDetail run = new RunDetail(userid, duration, postTime, xmlTimestamp, os, revision, gitHash, c, false, compressedXML,
diff --git a/testresults/src/org/labkey/testresults/TestResultsModule.java b/testresults/src/org/labkey/testresults/TestResultsModule.java
index 70958e6a..bdd474ec 100644
--- a/testresults/src/org/labkey/testresults/TestResultsModule.java
+++ b/testresults/src/org/labkey/testresults/TestResultsModule.java
@@ -22,7 +22,9 @@
import org.labkey.api.data.ContainerManager;
import org.labkey.api.module.DefaultModule;
import org.labkey.api.module.ModuleContext;
+import org.labkey.api.security.Directive;
import org.labkey.api.security.SecurityManager;
+import org.labkey.filters.ContentSecurityPolicyFilter;
import org.labkey.api.view.WebPartFactory;
import org.quartz.JobKey;
import org.quartz.Scheduler;
@@ -64,7 +66,7 @@ public String getName()
@Override
public @Nullable Double getSchemaVersion()
{
- return 13.40;
+ return 13.401;
}
@Override
@@ -84,6 +86,7 @@ protected Collection createWebPartFactories()
protected void init()
{
addController("testresults", TestResultsController.class);
+ TestResultsSchema.register(this);
}
@Override
@@ -92,6 +95,10 @@ public void doStartup(ModuleContext moduleContext)
// add a container listener so we'll know when our container is deleted:
ContainerManager.addContainerListener(new TestResultsContainerListener());
SecurityManager.registerAllowedConnectionSource("jquery-ui", "https://code.jquery.com/ui/1.13.2/jquery-ui.min.js");
+ // jQuery UI CSS and its background images are loaded from code.jquery.com.
+ // Register for style-src and img-src so they are not blocked by CSP.
+ ContentSecurityPolicyFilter.registerAllowedSources("jquery-ui-css", Directive.Style, "code.jquery.com");
+ ContentSecurityPolicyFilter.registerAllowedSources("jquery-ui-images", Directive.Image, "code.jquery.com");
}
@Override
diff --git a/testresults/src/org/labkey/testresults/TestResultsSchema.java b/testresults/src/org/labkey/testresults/TestResultsSchema.java
index 8d5d78de..d76d5ae9 100644
--- a/testresults/src/org/labkey/testresults/TestResultsSchema.java
+++ b/testresults/src/org/labkey/testresults/TestResultsSchema.java
@@ -16,69 +16,154 @@
package org.labkey.testresults;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.labkey.api.data.ColumnInfo;
+import org.labkey.api.data.Container;
+import org.labkey.api.data.ContainerFilter;
import org.labkey.api.data.DbSchema;
+import org.labkey.api.data.DbSchemaType;
+import org.labkey.api.data.ForeignKey;
import org.labkey.api.data.TableInfo;
import org.labkey.api.data.dialect.SqlDialect;
+import org.labkey.api.module.Module;
+import org.labkey.api.query.DefaultSchema;
+import org.labkey.api.query.FilteredTable;
+import org.labkey.api.query.QueryForeignKey;
+import org.labkey.api.query.QuerySchema;
+import org.labkey.api.query.UserSchema;
+import org.labkey.api.security.User;
+
+import java.util.Set;
/**
* User: Yuval Boss, yuval(at)uw.edu
* Date: 1/14/2015
*/
-public class TestResultsSchema
+public class TestResultsSchema extends UserSchema
{
- private static final TestResultsSchema _instance = new TestResultsSchema();
-
- public static TestResultsSchema getInstance()
+ public static final String SCHEMA_NAME = "testresults";
+ private static final String SCHEMA_DESCRIPTION = "TestResults nightly run data";
+
+ public static final String TABLE_TEST_RUNS = "testruns";
+ public static final String TABLE_USER = "user";
+ public static final String TABLE_USER_DATA = "userdata";
+ public static final String TABLE_TRAIN_RUNS = "trainruns";
+ public static final String TABLE_HANGS = "hangs";
+ public static final String TABLE_MEMORY_LEAKS = "memoryleaks";
+ public static final String TABLE_HANDLE_LEAKS = "handleleaks";
+ public static final String TABLE_TEST_PASSES = "testpasses";
+ public static final String TABLE_TEST_FAILS = "testfails";
+ public static final String TABLE_GLOBAL_SETTINGS = "globalsettings";
+
+ public TestResultsSchema(User user, Container container)
{
- return _instance;
+ super(SCHEMA_NAME, SCHEMA_DESCRIPTION, user, container, getSchema());
}
- private TestResultsSchema()
+ public static void register(Module module)
{
- // private constructor to prevent instantiation from
- // outside this class: this singleton should only be
- // accessed via org.labkey.testresults.TestResultsSchema.getInstance()
+ DefaultSchema.registerProvider(SCHEMA_NAME, new DefaultSchema.SchemaProvider(module)
+ {
+ @Override
+ public QuerySchema createSchema(DefaultSchema schema, Module module)
+ {
+ return new TestResultsSchema(schema.getUser(), schema.getContainer());
+ }
+ });
}
public static DbSchema getSchema()
{
- return DbSchema.get("testresults");
+ return DbSchema.get(SCHEMA_NAME, DbSchemaType.Module);
}
- public static TableInfo getTableInfoTestRuns() { return getSchema().getTable("testruns"); }
-
- public static TableInfo getTableInfoUser()
+ public static SqlDialect getSqlDialect()
{
- return getSchema().getTable("user");
+ return getSchema().getSqlDialect();
}
- public static TableInfo getTableInfoUserData() { return getSchema().getTable("userdata"); }
-
- public static TableInfo getTableInfoTrain() { return getSchema().getTable("trainruns"); }
-
- public static TableInfo getTableInfoHangs() { return getSchema().getTable("hangs"); }
-
- public static TableInfo getTableInfoMemoryLeaks() { return getSchema().getTable("memoryleaks"); }
-
- public static TableInfo getTableInfoHandleLeaks() { return getSchema().getTable("handleleaks"); }
-
- public static TableInfo getTableInfoTestPasses()
+ @Override
+ public @Nullable TableInfo createTable(@NotNull String name, @NotNull ContainerFilter cf)
{
- return getSchema().getTable("testpasses");
+ TableInfo dbTable = switch (name.toLowerCase())
+ {
+ case TABLE_TEST_RUNS -> getTableInfoTestRuns();
+ case TABLE_USER -> getTableInfoUser();
+ case TABLE_USER_DATA -> getTableInfoUserData();
+ case TABLE_TRAIN_RUNS -> getTableInfoTrain();
+ case TABLE_HANGS -> getTableInfoHangs();
+ case TABLE_MEMORY_LEAKS -> getTableInfoMemoryLeaks();
+ case TABLE_HANDLE_LEAKS -> getTableInfoHandleLeaks();
+ case TABLE_TEST_PASSES -> getTableInfoTestPasses();
+ case TABLE_TEST_FAILS -> getTableInfoTestFails();
+ case TABLE_GLOBAL_SETTINGS -> getTableInfoGlobalSettings();
+ default -> null;
+ };
+ if (dbTable == null)
+ return null;
+ FilteredTable table = new FilteredTable<>(dbTable, this, cf);
+ table.wrapAllColumns(true);
+ resolveSchemaForeignKeys(table, cf);
+ return table;
}
- public static TableInfo getTableInfoTestFails()
+ /**
+ * Converts DbSchema-level FKs (propagated by wrapAllColumns()) into UserSchema-level FKs
+ * so the Query Schema Browser renders hyperlinks and can navigate to target query grids.
+ * After wrapAllColumns(), columns whose FK targets are within this schema show up with an
+ * "undefined" schema name because the DbSchema FK has no UserSchema context. This method
+ * walks all columns and replaces any such FK with a proper QueryForeignKey.
+ */
+ private void resolveSchemaForeignKeys(FilteredTable table, ContainerFilter cf)
{
- return getSchema().getTable("testfails");
+ for (ColumnInfo col : table.getColumns())
+ {
+ ForeignKey fk = col.getFk();
+ if (fk == null)
+ continue;
+ String fkTable = fk.getLookupTableName();
+ String fkCol = fk.getLookupColumnName();
+ // Only replace FKs that target this schema (schema name comes through as null or
+ // SCHEMA_NAME from the DbSchema XML; skip FKs targeting other schemas like "core").
+ String fkSchema = fk.getLookupSchemaName();
+ if (fkTable != null && (fkSchema == null || SCHEMA_NAME.equalsIgnoreCase(fkSchema)))
+ {
+ var mutableCol = table.getMutableColumn(col.getName());
+ if (mutableCol != null)
+ mutableCol.setFk(QueryForeignKey.from(this, cf).to(fkTable, fkCol, null));
+ }
+ }
}
- public static TableInfo getTableInfoGlobalSettings()
+ @Override
+ public @NotNull Set getTableNames()
{
- return getSchema().getTable("globalsettings");
+ return Set.of(
+ TABLE_TEST_RUNS,
+ TABLE_USER,
+ TABLE_USER_DATA,
+ TABLE_TRAIN_RUNS,
+ TABLE_HANGS,
+ TABLE_MEMORY_LEAKS,
+ TABLE_HANDLE_LEAKS,
+ TABLE_TEST_PASSES,
+ TABLE_TEST_FAILS,
+ TABLE_GLOBAL_SETTINGS);
}
- public static SqlDialect getSqlDialect()
- {
- return getSchema().getSqlDialect();
- }
+ // ---------------------------------------------------------------------------
+ // Static table accessors — used throughout TestResultsController
+ // ---------------------------------------------------------------------------
+
+ public static TableInfo getTableInfoTestRuns() { return getSchema().getTable(TABLE_TEST_RUNS); }
+ public static TableInfo getTableInfoUser() { return getSchema().getTable(TABLE_USER); }
+ public static TableInfo getTableInfoUserData() { return getSchema().getTable(TABLE_USER_DATA); }
+ public static TableInfo getTableInfoTrain() { return getSchema().getTable(TABLE_TRAIN_RUNS); }
+ public static TableInfo getTableInfoHangs() { return getSchema().getTable(TABLE_HANGS); }
+ public static TableInfo getTableInfoMemoryLeaks() { return getSchema().getTable(TABLE_MEMORY_LEAKS); }
+ public static TableInfo getTableInfoHandleLeaks() { return getSchema().getTable(TABLE_HANDLE_LEAKS); }
+ public static TableInfo getTableInfoTestPasses() { return getSchema().getTable(TABLE_TEST_PASSES); }
+ public static TableInfo getTableInfoTestFails() { return getSchema().getTable(TABLE_TEST_FAILS); }
+ public static TableInfo getTableInfoGlobalSettings() { return getSchema().getTable(TABLE_GLOBAL_SETTINGS); }
}
diff --git a/testresults/src/org/labkey/testresults/TestResultsWebPart.java b/testresults/src/org/labkey/testresults/TestResultsWebPart.java
index c4a632d4..2a8a9a53 100644
--- a/testresults/src/org/labkey/testresults/TestResultsWebPart.java
+++ b/testresults/src/org/labkey/testresults/TestResultsWebPart.java
@@ -28,7 +28,7 @@ public WebPartView> getWebPartView(@NotNull ViewContext portalCtx, Portal.@Not
TestsDataBean bean = null;
try
{
- bean = TestResultsController.getRunDownBean(portalCtx.getUser(), c, portalCtx);
+ bean = TestResultsController.getRunDownBean(portalCtx.getUser(), c);
}
catch (ParseException | IOException e)
{
diff --git a/testresults/src/org/labkey/testresults/view/TestsDataBean.java b/testresults/src/org/labkey/testresults/view/TestsDataBean.java
index 55685314..61049738 100644
--- a/testresults/src/org/labkey/testresults/view/TestsDataBean.java
+++ b/testresults/src/org/labkey/testresults/view/TestsDataBean.java
@@ -50,6 +50,8 @@ public class TestsDataBean
private String viewType;
private Date startDate;
private Date endDate;
+ private String username;
+ private String dataInclude;
private Integer boundaryWarning;
private Integer boundaryError;
@@ -99,6 +101,26 @@ public void setViewType(String viewType)
this.viewType = viewType;
}
+ public String getUsername()
+ {
+ return username;
+ }
+
+ public void setUsername(String username)
+ {
+ this.username = username;
+ }
+
+ public String getDataInclude()
+ {
+ return dataInclude;
+ }
+
+ public void setDataInclude(String dataInclude)
+ {
+ this.dataInclude = dataInclude;
+ }
+
// Getters and Setters for fields
public RunDetail[] getRuns() {
RunDetail[] r = runs.values().toArray(new RunDetail[0]);
diff --git a/testresults/src/org/labkey/testresults/view/failureDetail.jsp b/testresults/src/org/labkey/testresults/view/failureDetail.jsp
index 6537303b..3d675525 100644
--- a/testresults/src/org/labkey/testresults/view/failureDetail.jsp
+++ b/testresults/src/org/labkey/testresults/view/failureDetail.jsp
@@ -392,19 +392,31 @@ $(document).ready(function() {
$("#problem-type-selection input").change(changeProblemType);
$("#problem-type-selection input[value=" + <%=q(problemType)%> + "]").prop("checked", true).trigger("change");
- // Initialize sortable table.
+ // Initialize sortable table. tablesorter 2.0.5b requires numeric column
+ // indices in `headers`, so look up the problem column's index from its ID
+ // — keeps the named selector as the source of truth if columns are reordered.
+ var problemColIdx = $("#failurestatstable thead th").index($("#col-problem"));
+ var headers = {};
+ if (problemColIdx >= 0) {
+ headers[problemColIdx] = { sorter: false };
+ } else {
+ console.warn("failureDetail.jsp: #col-problem header not found; problem column will be sortable.");
+ }
+ // Skip sortList/sortAppend on an empty tbody — tablesorter throws
+ // when asked to apply an initial sort with no rows to sort.
+ var hasRows = $("#failurestatstable tbody tr").length > 0;
$("#failurestatstable").tablesorter({
widthFixed : true,
resizable: true,
widgets: ['zebra'],
- headers : { "#col-problem": { sorter: false } },
+ headers : headers,
cssAsc: "headerSortUp",
cssDesc: "headerSortDown",
ignoreCase: true,
- sortList: [[1, 1]], // initial sort by post time descending
- sortAppend: {
+ sortList: hasRows ? [[1, 1]] : [], // initial sort by post time descending
+ sortAppend: hasRows ? {
0: [[ 1, 'a' ]] // secondary sort by date ascending
- },
+ } : {},
theme: 'default'
});
});
diff --git a/testresults/src/org/labkey/testresults/view/runDetail.jsp b/testresults/src/org/labkey/testresults/view/runDetail.jsp
index 3013117d..c4895a38 100644
--- a/testresults/src/org/labkey/testresults/view/runDetail.jsp
+++ b/testresults/src/org/labkey/testresults/view/runDetail.jsp
@@ -49,11 +49,11 @@
win.document.write('' + data + ' ');
};
var showLog = function() {
- $.get('<%=h(new ActionURL(TestResultsController.ViewLogAction.class, c).addParameter("runid", runId))%>', csrf_header,
+ $.get('<%=h(new ActionURL(TestResultsController.ViewLogAction.class, c).addParameter("runId", runId))%>', csrf_header,
function(data) { popupData(data.log); }, "json");
};
var showXml = function() {
- $.get('<%=h(new ActionURL(TestResultsController.ViewXmlAction.class, c).addParameter("runid", runId))%>', csrf_header,
+ $.get('<%=h(new ActionURL(TestResultsController.ViewXmlAction.class, c).addParameter("runId", runId))%>', csrf_header,
function(data) { popupData(data.xml); }, "json");
};
@@ -85,7 +85,7 @@
Run Id: <%=run.getId()%>
- User : "><%=h(run.getUserName())%>
+ User : "><%=h(run.getUserName())%>
OS: <%=h(run.getOs())%>
Revision: <%=h(run.getRevisionFull())%>
Passed Tests : <%=run.getPasses().length%>
@@ -216,7 +216,7 @@ if (leaks.length > 0) { %>
if (data.Success) {
location.reload();
} else {
- alert(data);
+ alert("Failed to update training set." + (data.error ? " " + data.error : ""));
}
}, "json");
});
diff --git a/testresults/src/org/labkey/testresults/view/rundown.jsp b/testresults/src/org/labkey/testresults/view/rundown.jsp
index 0c42692e..63088e79 100644
--- a/testresults/src/org/labkey/testresults/view/rundown.jsp
+++ b/testresults/src/org/labkey/testresults/view/rundown.jsp
@@ -563,7 +563,7 @@ $(function() {
$(self).text(isTrain ? 'Untrain' : 'Train');
return;
}
- alert("Failure removing run. Contact Yuval");
+ alert("Failed to update training set." + (data.error ? " " + data.error : ""));
}, "json");
});
diff --git a/testresults/src/org/labkey/testresults/view/trainingdata.jsp b/testresults/src/org/labkey/testresults/view/trainingdata.jsp
index f19abf73..69d4d5cd 100644
--- a/testresults/src/org/labkey/testresults/view/trainingdata.jsp
+++ b/testresults/src/org/labkey/testresults/view/trainingdata.jsp
@@ -167,7 +167,7 @@
- "><%=h(user.getUsername())%>
+ "><%=h(user.getUsername())%>
<% if (user.isActive()) { %>
@@ -220,7 +220,7 @@
<% for (User user : noRunsForUser) { %>
- ">
+ ">
<%=h(user.getUsername())%>
@@ -254,7 +254,7 @@
}
return;
}
- alert("Failure removing run. Contact Yuval");
+ alert("Failed to update training set." + (data.error ? " " + data.error : ""));
}, "json");
});
@@ -345,7 +345,7 @@
url.searchParams.set('maxRuns', maxRuns);
url.searchParams.set('minRuns', minRuns);
$.post(url.toString(), csrf_header, function(data) {
- if (data.success) {
+ if (data.Success) {
$('#retrain-all-status').text('Retrained ' + data.usersRetrained + ' computers with ' + data.totalTrainRuns + ' runs. Reloading...');
location.reload();
} else {
diff --git a/testresults/src/org/labkey/testresults/view/user.jsp b/testresults/src/org/labkey/testresults/view/user.jsp
index 4ef8a471..7396e469 100644
--- a/testresults/src/org/labkey/testresults/view/user.jsp
+++ b/testresults/src/org/labkey/testresults/view/user.jsp
@@ -48,18 +48,13 @@
User userObj = data.getUsers().length == 1 ? data.getUsers()[0] : null;
- HttpServletRequest req = getViewContext().getRequest();
- String startDate = req.getParameter("start");
- String endDate = req.getParameter("end");
- String user = req.getParameter("user");
- boolean showSingleUser = user != null && !user.isEmpty();
DateFormat df = new SimpleDateFormat("MM/dd/yyyy");
Date today = new Date();
- if (startDate == null)
- startDate = df.format(today);
- if (endDate == null)
- endDate = df.format(today);
- String dataInclude = req.getParameter("datainclude");
+ String startDate = data.getStartDate() != null ? df.format(data.getStartDate()) : df.format(today);
+ String endDate = data.getEndDate() != null ? df.format(data.getEndDate()) : df.format(today);
+ String user = data.getUsername();
+ boolean showSingleUser = user != null && !user.isEmpty();
+ String dataInclude = data.getDataInclude();
if (dataInclude == null ||
(!dataInclude.equalsIgnoreCase("date") && !dataInclude.equalsIgnoreCase("train") && !dataInclude.equalsIgnoreCase("both")))
dataInclude = "date";
@@ -132,7 +127,7 @@
<%=h(user)%>
<%
String headerDate = startDate;
- if (getViewContext().getRequest().getParameter("end") != null)
+ if (!startDate.equals(endDate))
headerDate += " - " + endDate;
%>
<%=h(headerDate)%>
@@ -228,7 +223,7 @@
}
let url = <%=jsURL(new ActionURL(TestResultsController.ShowUserAction.class, c))%>;
- url.searchParams.set('user', $("#users").val() || "");
+ url.searchParams.set('username', $("#users").val() || "");
url.searchParams.set('start', startDate);
url.searchParams.set('end', endDate);
url.searchParams.set('datainclude', $("#data-include").val());
@@ -268,7 +263,7 @@
trainObj.setAttribute("runTrained", !isTrainRun);
trainObj.innerHTML = !isTrainRun ? "Remove from training set" : "Add to training set";
} else {
- alert(data);
+ alert("Failed to update training set." + (data.error ? " " + data.error : ""));
}
}, "json");
});
diff --git a/testresults/test/sampledata/testresults/pc1-run-0114-disposable.xml b/testresults/test/sampledata/testresults/pc1-run-0114-disposable.xml
new file mode 100644
index 00000000..c5620843
--- /dev/null
+++ b/testresults/test/sampledata/testresults/pc1-run-0114-disposable.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/testresults/test/sampledata/testresults/pc1-run-0115-clean.xml b/testresults/test/sampledata/testresults/pc1-run-0115-clean.xml
new file mode 100644
index 00000000..1f2964a3
--- /dev/null
+++ b/testresults/test/sampledata/testresults/pc1-run-0115-clean.xml
@@ -0,0 +1,312 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+# Nightly started Thursday, January 15, 2026 9:00 PM
+
+[21:00] 1.0 TestAlpha (fr)
+[21:03] 1.0 TestBeta (fr)
+[21:07] 1.0 TestGamma (fr)
+[21:10] 1.0 TestDelta (fr)
+[21:14] 1.0 TestEpsilon (fr)
+[21:18] 1.0 Test006 (fr)
+[21:21] 1.0 Test007 (fr)
+[21:25] 1.0 Test008 (fr)
+[21:28] 1.0 Test009 (fr)
+[21:32] 1.0 Test010 (fr)
+[21:36] 1.0 Test011 (fr)
+[21:39] 1.0 Test012 (fr)
+[21:43] 1.0 Test013 (fr)
+[21:46] 1.0 Test014 (fr)
+[21:50] 1.0 Test015 (fr)
+[21:54] 1.0 Test016 (fr)
+[21:57] 1.0 Test017 (fr)
+[22:01] 1.0 Test018 (fr)
+[22:04] 1.0 Test019 (fr)
+[22:08] 1.0 Test020 (fr)
+[22:12] 1.0 Test021 (fr)
+[22:15] 1.0 Test022 (fr)
+[22:19] 1.0 Test023 (fr)
+[22:22] 1.0 Test024 (fr)
+[22:26] 1.0 Test025 (fr)
+[22:30] 1.0 Test026 (fr)
+[22:33] 1.0 Test027 (fr)
+[22:37] 1.0 Test028 (fr)
+[22:40] 1.0 Test029 (fr)
+[22:44] 1.0 Test030 (fr)
+[22:48] 1.0 Test031 (fr)
+[22:51] 1.0 Test032 (fr)
+[22:55] 1.0 Test033 (fr)
+[22:58] 1.0 Test034 (fr)
+[23:02] 1.0 Test035 (fr)
+[23:06] 1.0 Test036 (fr)
+[23:09] 1.0 Test037 (fr)
+[23:13] 1.0 Test038 (fr)
+[23:16] 1.0 Test039 (fr)
+[23:20] 1.0 Test040 (fr)
+[23:24] 1.0 Test041 (fr)
+[23:27] 1.0 Test042 (fr)
+[23:31] 1.0 Test043 (fr)
+[23:34] 1.0 Test044 (fr)
+[23:38] 1.0 Test045 (fr)
+[23:42] 1.0 Test046 (fr)
+[23:45] 1.0 Test047 (fr)
+[23:49] 1.0 Test048 (fr)
+[23:52] 1.0 Test049 (fr)
+[23:56] 1.0 Test050 (fr)
+[00:00] 1.0 Test051 (fr)
+[00:03] 1.0 Test052 (fr)
+[00:07] 1.0 Test053 (fr)
+[00:10] 1.0 Test054 (fr)
+[00:14] 1.0 Test055 (fr)
+[00:18] 1.0 Test056 (fr)
+[00:21] 1.0 Test057 (fr)
+[00:25] 1.0 Test058 (fr)
+[00:28] 1.0 Test059 (fr)
+[00:32] 1.0 Test060 (fr)
+[00:36] 1.0 Test061 (fr)
+[00:39] 1.0 Test062 (fr)
+[00:43] 1.0 Test063 (fr)
+[00:46] 1.0 Test064 (fr)
+[00:50] 1.0 Test065 (fr)
+[00:54] 1.0 Test066 (fr)
+[00:57] 1.0 Test067 (fr)
+[01:01] 1.0 Test068 (fr)
+[01:04] 1.0 Test069 (fr)
+[01:08] 1.0 Test070 (fr)
+[01:12] 1.0 Test071 (fr)
+[01:15] 1.0 Test072 (fr)
+[01:19] 1.0 Test073 (fr)
+[01:22] 1.0 Test074 (fr)
+[01:26] 1.0 Test075 (fr)
+[01:30] 1.0 Test076 (fr)
+[01:33] 1.0 Test077 (fr)
+[01:37] 1.0 Test078 (fr)
+[01:40] 1.0 Test079 (fr)
+[01:44] 1.0 Test080 (fr)
+[01:48] 1.0 Test081 (fr)
+[01:51] 1.0 Test082 (fr)
+[01:55] 1.0 Test083 (fr)
+[01:58] 1.0 Test084 (fr)
+[02:02] 1.0 Test085 (fr)
+[02:06] 1.0 Test086 (fr)
+[02:09] 1.0 Test087 (fr)
+[02:13] 1.0 Test088 (fr)
+[02:16] 1.0 Test089 (fr)
+[02:20] 1.0 Test090 (fr)
+[02:24] 1.0 Test091 (fr)
+[02:27] 1.0 Test092 (fr)
+[02:31] 1.0 Test093 (fr)
+[02:34] 1.0 Test094 (fr)
+[02:38] 1.0 Test095 (fr)
+[02:42] 1.0 Test096 (fr)
+[02:45] 1.0 Test097 (fr)
+[02:49] 1.0 Test098 (fr)
+[02:52] 1.0 Test099 (fr)
+[02:56] 1.0 Test100 (fr)
+[03:00] 1.0 Test101 (fr)
+[03:03] 1.0 Test102 (fr)
+[03:07] 1.0 Test103 (fr)
+[03:10] 1.0 Test104 (fr)
+[03:14] 1.0 Test105 (fr)
+[03:18] 1.0 Test106 (fr)
+[03:21] 1.0 Test107 (fr)
+[03:25] 1.0 Test108 (fr)
+[03:28] 1.0 Test109 (fr)
+[03:32] 1.0 Test110 (fr)
+[03:36] 1.0 Test111 (fr)
+[03:39] 1.0 Test112 (fr)
+[03:43] 1.0 Test113 (fr)
+[03:46] 1.0 Test114 (fr)
+[03:50] 1.0 Test115 (fr)
+[03:54] 1.0 Test116 (fr)
+[03:57] 1.0 Test117 (fr)
+[04:01] 1.0 Test118 (fr)
+[04:04] 1.0 Test119 (fr)
+[04:08] 1.0 Test120 (fr)
+[04:12] 1.0 Test121 (fr)
+[04:15] 1.0 Test122 (fr)
+[04:19] 1.0 Test123 (fr)
+[04:22] 1.0 Test124 (fr)
+[04:26] 1.0 Test125 (fr)
+[04:30] 1.0 Test126 (fr)
+[04:33] 1.0 Test127 (fr)
+[04:37] 1.0 Test128 (fr)
+[04:40] 1.0 Test129 (fr)
+[04:44] 1.0 Test130 (fr)
+[04:48] 1.0 Test131 (fr)
+[04:51] 1.0 Test132 (fr)
+[04:55] 1.0 Test133 (fr)
+[04:58] 1.0 Test134 (fr)
+[05:02] 1.0 Test135 (fr)
+[05:06] 1.0 Test136 (fr)
+[05:09] 1.0 Test137 (fr)
+[05:13] 1.0 Test138 (fr)
+[05:16] 1.0 Test139 (fr)
+[05:20] 1.0 Test140 (fr)
+[05:24] 1.0 Test141 (fr)
+[05:27] 1.0 Test142 (fr)
+[05:31] 1.0 Test143 (fr)
+[05:34] 1.0 Test144 (fr)
+[05:38] 1.0 Test145 (fr)
+[05:42] 1.0 Test146 (fr)
+[05:45] 1.0 Test147 (fr)
+[05:49] 1.0 Test148 (fr)
+[05:52] 1.0 Test149 (fr)
+[05:56] 1.0 Test150 (fr)
+
+
diff --git a/testresults/test/sampledata/testresults/pc1-run-0116-failures.xml b/testresults/test/sampledata/testresults/pc1-run-0116-failures.xml
new file mode 100644
index 00000000..d2904402
--- /dev/null
+++ b/testresults/test/sampledata/testresults/pc1-run-0116-failures.xml
@@ -0,0 +1,167 @@
+
+
+
+
+
+ System.Exception: Assertion failed at TestFailOne line 42
+ at Skyline.Test.TestFailOne() in TestFailOne.cs:line 42
+
+
+ System.NullReferenceException: Object reference not set to an instance of an object
+ at Skyline.Test.TestFailTwo() in TestFailTwo.cs:line 17
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/testresults/test/sampledata/testresults/pc1-run-0117-leaks.xml b/testresults/test/sampledata/testresults/pc1-run-0117-leaks.xml
new file mode 100644
index 00000000..d0c2ac45
--- /dev/null
+++ b/testresults/test/sampledata/testresults/pc1-run-0117-leaks.xml
@@ -0,0 +1,161 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/testresults/test/sampledata/testresults/pc2-run-0115-clean.xml b/testresults/test/sampledata/testresults/pc2-run-0115-clean.xml
new file mode 100644
index 00000000..05f8e684
--- /dev/null
+++ b/testresults/test/sampledata/testresults/pc2-run-0115-clean.xml
@@ -0,0 +1,158 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/testresults/test/sampledata/testresults/pc2-run-0116-failures.xml b/testresults/test/sampledata/testresults/pc2-run-0116-failures.xml
new file mode 100644
index 00000000..95d16b64
--- /dev/null
+++ b/testresults/test/sampledata/testresults/pc2-run-0116-failures.xml
@@ -0,0 +1,163 @@
+
+
+
+
+
+ System.Exception: Assertion failed at TestFailOne line 42
+ at Skyline.Test.TestFailOne() in TestFailOne.cs:line 42
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/testresults/test/sampledata/testresults/pc2-run-0117-leaks.xml b/testresults/test/sampledata/testresults/pc2-run-0117-leaks.xml
new file mode 100644
index 00000000..c8bbe70b
--- /dev/null
+++ b/testresults/test/sampledata/testresults/pc2-run-0117-leaks.xml
@@ -0,0 +1,160 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/testresults/test/src/org/labkey/test/tests/testresults/TestResultsTest.java b/testresults/test/src/org/labkey/test/tests/testresults/TestResultsTest.java
new file mode 100644
index 00000000..28b57a81
--- /dev/null
+++ b/testresults/test/src/org/labkey/test/tests/testresults/TestResultsTest.java
@@ -0,0 +1,910 @@
+/*
+ * Copyright (c) 2026 LabKey Corporation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.labkey.test.tests.testresults;
+
+import org.apache.hc.client5.http.classic.methods.HttpGet;
+import org.apache.hc.client5.http.classic.methods.HttpPost;
+import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
+import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.io.entity.EntityUtils;
+import org.json.JSONObject;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.labkey.remoteapi.Connection;
+import org.labkey.remoteapi.query.SelectRowsCommand;
+import org.labkey.remoteapi.query.SelectRowsResponse;
+import org.labkey.remoteapi.query.Sort;
+import org.labkey.test.BaseWebDriverTest;
+import org.labkey.test.Locator;
+import org.labkey.test.TestFileUtils;
+import org.labkey.test.WebTestHelper;
+import org.labkey.test.categories.External;
+import org.labkey.test.categories.MacCossLabModules;
+import org.labkey.test.util.APIContainerHelper;
+import org.labkey.test.util.APITestHelper;
+import org.labkey.test.util.LogMethod;
+import org.labkey.test.util.PortalHelper;
+import org.labkey.test.util.PostgresOnlyTest;
+import org.labkey.test.util.TextSearcher;
+
+import java.io.File;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+@Category({External.class, MacCossLabModules.class})
+@BaseWebDriverTest.ClassTimeout(minutes = 5)
+public class TestResultsTest extends BaseWebDriverTest implements PostgresOnlyTest
+{
+ private static final String PROJECT_NAME = "TestResultsTest" + TRICKY_CHARACTERS_FOR_PROJECT_NAMES;
+ static final String COMPUTER_NAME_1 = "TEST-PC-1";
+ static final String COMPUTER_NAME_2 = "TEST-PC-2";
+
+ private static final Locator SUBMIT_BUTTON = Locator.css("input[type='submit'][value='Submit']");
+
+ // XPath for the problems matrix table (header cell contains "Fail: | Leak: | Hang:")
+ private static final String PROBLEMS_TABLE_XPATH =
+ "//table[contains(@class,'decoratedtable')]" +
+ "[.//td[contains(.,'Fail:') and contains(.,'Leak:') and contains(.,'Hang:')]]";
+
+ // Run IDs populated in @BeforeClass, used across test methods
+ private static int _disposableRunId = -1;
+ private static int _cleanRunId = -1;
+ private static int _failRunId = -1;
+ private static int _leakRunId = -1;
+
+ @BeforeClass
+ public static void setupProject()
+ {
+ TestResultsTest init = getCurrentTest();
+ init.doSetup();
+ }
+
+ @LogMethod
+ private void doSetup()
+ {
+ _containerHelper.createProject(PROJECT_NAME, null);
+ _containerHelper.enableModule("TestResults");
+ new PortalHelper(this).addWebPart("Test Results");
+
+ // TEST-PC-1 runs
+ postSampleXml("testresults/pc1-run-0114-disposable.xml");
+ postSampleXml("testresults/pc1-run-0115-clean.xml");
+ postSampleXml("testresults/pc1-run-0116-failures.xml");
+ postSampleXml("testresults/pc1-run-0117-leaks.xml");
+
+ // TEST-PC-2 runs on the same dates
+ postSampleXml("testresults/pc2-run-0115-clean.xml");
+ postSampleXml("testresults/pc2-run-0116-failures.xml");
+ postSampleXml("testresults/pc2-run-0117-leaks.xml");
+
+ // All runs in this fresh container are our sample runs, sorted by posttime ascending.
+ // PC2 runs are interleaved with PC1 runs, so identify PC1 runs by computer name.
+ List> runs = queryRuns();
+ assertEquals("Expected 7 posted runs", 7, runs.size());
+
+ List> pc1Runs = runs.stream()
+ .filter(r -> COMPUTER_NAME_1.equals(r.get("userid/username")))
+ .toList();
+ assertEquals("Expected 4 " + COMPUTER_NAME_1 + " runs", 4, pc1Runs.size());
+ _disposableRunId = (Integer) pc1Runs.get(0).get("id");
+ _cleanRunId = (Integer) pc1Runs.get(1).get("id");
+ _failRunId = (Integer) pc1Runs.get(2).get("id");
+ _leakRunId = (Integer) pc1Runs.get(3).get("id");
+ }
+
+ /**
+ * Posts a sample XML file to PostAction.
+ */
+ private void postSampleXml(String sampleDataRelativePath)
+ {
+ File xmlFile = TestFileUtils.getSampleData(sampleDataRelativePath);
+ String postUrl = WebTestHelper.buildURL("testresults", PROJECT_NAME, "post");
+
+ try (CloseableHttpClient httpClient = WebTestHelper.getHttpClient())
+ {
+ HttpPost request = new HttpPost(postUrl);
+ APITestHelper.injectCookies(request);
+ request.setEntity(MultipartEntityBuilder.create()
+ .addBinaryBody("xml_file", xmlFile, ContentType.TEXT_XML, xmlFile.getName())
+ .build());
+ httpClient.execute(request, response -> {
+ String body = EntityUtils.toString(response.getEntity());
+ JSONObject json = new JSONObject(body);
+ assertTrue("PostAction failed for " + xmlFile.getName() + ": " + body,
+ json.optBoolean("Success", false));
+ return null;
+ });
+ }
+ catch (Exception e)
+ {
+ throw new RuntimeException("Failed to post sample XML:" + sampleDataRelativePath, e);
+ }
+ }
+
+ /**
+ * Queries all testruns in the test container, sorted by posttime ascending.
+ */
+ private List> queryRuns()
+ {
+ try
+ {
+ Connection connection = WebTestHelper.getRemoteApiConnection();
+ SelectRowsCommand cmd = new SelectRowsCommand("testresults", "testruns");
+ cmd.setSorts(List.of(new Sort("posttime")));
+ cmd.setColumns(List.of("id", "posttime", "userid/username", "passedtests", "failedtests", "leakedtests"));
+ SelectRowsResponse response = cmd.execute(connection, PROJECT_NAME);
+ return response.getRows();
+ }
+ catch (Exception e)
+ {
+ throw new RuntimeException("Failed to query test runs", e);
+ }
+ }
+
+ @Before
+ public void navigateToProject()
+ {
+ goToProjectHome(PROJECT_NAME);
+ }
+
+ // -------------------------------------------------------------------------
+ // Tests
+ // -------------------------------------------------------------------------
+
+ @Test
+ public void testBeginPage()
+ {
+ // Start at 01/17/2026 via URL so the datepicker opens near our sample data dates
+ beginAt(WebTestHelper.buildRelativeUrl("testresults", PROJECT_NAME, "begin", Map.of("end", "01/17/2026")));
+ assertTextPresent(COMPUTER_NAME_1, COMPUTER_NAME_2);
+ assertTextPresent("Top Failures");
+ assertTextNotPresent("Top Leaks");
+
+ // Verify the problems matrix: TestFailOne fails on both PCs (2 icons),
+ // TestFailTwo fails only on PC-1 (1 icon)
+ assertProblemsMatrixPresent(COMPUTER_NAME_1, COMPUTER_NAME_2);
+ assertProblemIconCount("TestFailOne", "fail.png", 2);
+ assertProblemIconCount("TestFailTwo", "fail.png", 1);
+
+ // Verify Top Failures summary table: occurrences across all runs in the view period
+ assertTopSummaryEntry("Top Failures", "TestFailOne", 2);
+ assertTopSummaryEntry("Top Failures", "TestFailTwo", 1);
+
+ // Verify the datepicker reflects our starting date, then navigate BACKWARD
+ // one day to 01/16/2026 — clean run, no failures or leaks
+ verifyDateInDatepicker(1, 17, 2026);
+ goToPrevDay(1);
+ verifyDateInDatepicker(1, 16, 2026);
+ assertTextPresent(COMPUTER_NAME_1, COMPUTER_NAME_2);
+ assertTextNotPresent("Top Failures");
+ assertTextNotPresent("Top Leaks");
+
+ // Navigate FORWARD two days to 01/18/2026 — leaks on both PCs
+ goToNextDay(2);
+ verifyDateInDatepicker(1, 18, 2026);
+ assertTextPresent(COMPUTER_NAME_1, COMPUTER_NAME_2);
+ assertTextPresent("Top Failures"); // cumulative — failures from 01/17 still in the view period
+ assertTextPresent("Top Leaks");
+
+ // Verify the problems matrix: TestWithMemoryLeak leaks on both PCs (2 icons),
+ // TestWithHandleLeak leaks only on PC-1 (1 icon)
+ assertProblemsMatrixPresent(COMPUTER_NAME_1, COMPUTER_NAME_2);
+ assertProblemIconCount("TestWithMemoryLeak", "leak.png", 2);
+ assertProblemIconCount("TestWithHandleLeak", "leak.png", 1);
+
+ // Verify Top Leaks summary table: occurrences and mean leak values
+ assertTopSummaryEntry("Top Leaks", "TestWithMemoryLeak", 2);
+ assertTopSummaryEntry("Top Leaks", "TestWithHandleLeak", 1);
+ assertTopLeakMean("TestWithMemoryLeak", "3 kb");
+ assertTopLeakMean("TestWithHandleLeak", "5 handles");
+
+ // Verify viewType selector defaults to Month
+ Locator viewTypeSelect = Locator.id("viewType");
+ assertEquals("Default viewType should be Month", "Month", getSelectedOptionText(viewTypeSelect));
+
+ // Select Week — verify URL parameter and selector state after page reload
+ doAndWaitForPageToLoad(() -> selectOptionByValue(viewTypeSelect, "wk"));
+ assertEquals("wk", getUrlParam("viewType"));
+ assertEquals("Week", getSelectedOptionText(viewTypeSelect));
+
+ // Select Year
+ doAndWaitForPageToLoad(() -> selectOptionByValue(viewTypeSelect, "yr"));
+ assertEquals("yr", getUrlParam("viewType"));
+ assertEquals("Year", getSelectedOptionText(viewTypeSelect));
+
+ // Select back to Month
+ doAndWaitForPageToLoad(() -> selectOptionByValue(viewTypeSelect, "mo"));
+ assertEquals("mo", getUrlParam("viewType"));
+ assertEquals("Month", getSelectedOptionText(viewTypeSelect));
+ }
+
+ @Test
+ public void testShowRunPage()
+ {
+ // Navigate to user page with sample data dates to get "run details" links
+ navigateToUserPageWithDateRange();
+
+ // Runs are sorted descending by date: row 0 = 01/18 (leaks), row 1 = 01/17 (failures), row 2 = 01/16 (clean)
+
+ // Click the first "run details" link (01/18 — leaks run)
+ clickAndWait(Locator.linkWithText("run details").index(0));
+ assertTextPresent(COMPUTER_NAME_1, "Passed Tests : 150", "Failures : 0", "Leaks : 2");
+ assertTextPresent("TestWithMemoryLeak", "TestWithHandleLeak");
+
+ // Sort by Duration (descending) and verify the sort parameter is applied
+ clickAndWait(Locator.linkWithText("Duration"));
+ assertEquals("duration", getUrlParam("filter"));
+
+ // Sort by Managed Memory (descending) — tests later in the run have higher memory,
+ // so TestWithHandleLeak (id=100) should appear before TestAlpha (id=1)
+ clickAndWait(Locator.linkContainingText("Managed Memory"));
+ assertEquals("managed", getUrlParam("filter"));
+ assertTestPassesSortedAs("TestWithHandleLeak", "TestAlpha");
+
+ // Sort by Total Memory (descending) — same ordering principle
+ clickAndWait(Locator.linkContainingText("Total Memory"));
+ assertEquals("total", getUrlParam("filter"));
+ assertTestPassesSortedAs("TestWithHandleLeak", "TestAlpha");
+
+ // Navigate to user page again for the failures run
+ navigateToUserPageWithDateRange();
+ clickAndWait(Locator.linkWithText("run details").index(1));
+ assertTextPresent(COMPUTER_NAME_1, "Passed Tests : 150", "Failures : 2", "Leaks : 0");
+ assertTextPresent("TestFailOne", "TestFailTwo");
+
+ // Navigate to user page again for the clean run
+ navigateToUserPageWithDateRange();
+ clickAndWait(Locator.linkWithText("run details").index(2));
+ assertTextPresent(COMPUTER_NAME_1, "Passed Tests : 150", "Failures : 0", "Leaks : 0");
+ }
+
+ @Test
+ public void testRunLookup()
+ {
+ // Look up the leaks run
+ navigateToRunById(_leakRunId);
+ assertTextPresent(COMPUTER_NAME_1, "Passed Tests : 150", "Failures : 0", "Leaks : 2");
+ assertTextPresent("TestWithMemoryLeak", "TestWithHandleLeak");
+
+ // Look up the failures run
+ navigateToRunById(_failRunId);
+ assertTextPresent(COMPUTER_NAME_1, "Passed Tests : 150", "Failures : 2", "Leaks : 0");
+ assertTextPresent("TestFailOne", "TestFailTwo");
+
+ // Look up the clean run
+ navigateToRunById(_cleanRunId);
+ assertTextPresent(COMPUTER_NAME_1, "Passed Tests : 150", "Failures : 0", "Leaks : 0");
+ }
+
+ @Test
+ public void testLongTermPage()
+ {
+ // Navigate to Long Term page via tab click
+ goToProjectHome(PROJECT_NAME);
+ clickAndWait(Locator.linkWithText("Long Term"));
+
+ // Use the viewType selector to switch between views
+ Locator viewTypeSelect = Locator.id("view-type-combobox");
+
+ doAndWaitForPageToLoad(() -> selectOptionByValue(viewTypeSelect, "wk"));
+ assertEquals("wk", getUrlParam("viewType"));
+
+ doAndWaitForPageToLoad(() -> selectOptionByValue(viewTypeSelect, "mo"));
+ assertEquals("mo", getUrlParam("viewType"));
+
+ doAndWaitForPageToLoad(() -> selectOptionByValue(viewTypeSelect, "yr"));
+ assertEquals("yr", getUrlParam("viewType"));
+ }
+
+ @Test
+ public void testShowFailuresPage()
+ {
+ // All "Viewing data for: - " assertions below use
+ // assertTextPresentInThisOrder with just the MM/dd/yyyy date parts
+ // (no time-of-day). The controller stamps both start and end to
+ // 08:01 via setToEightAM, but the start wall-clock time can shift
+ // across DST boundaries (e.g. start 07:01 PST, end 08:01 PDT when
+ // the window crosses spring-forward), so asserting just the dates
+ // keeps the test stable.
+
+ // --- Path 1: navigate via runDetail.jsp ---
+ // The "TestFailOne" link on runDetail.jsp does NOT set the `end`
+ // URL parameter (only `failedTest` and `viewType=wk`), so the
+ // controller falls back to `new Date()` — the displayed end date
+ // is today. Capture it once so we don't drift across a midnight
+ // rollover during the test.
+ LocalDate today = LocalDate.now();
+ DateTimeFormatter dateFmt = DateTimeFormatter.ofPattern("MM/dd/yyyy");
+ String todayStr = today.format(dateFmt);
+
+ navigateToRunById(_failRunId);
+ clickAndWait(Locator.linkWithText("TestFailOne"));
+ assertTextPresent("TestFailOne");
+
+ // Default view is Week → start = today - 7 days
+ Locator viewTypeSelect = Locator.id("view-type-combobox");
+ assertEquals("Week", getSelectedOptionText(viewTypeSelect));
+ assertTextPresentInThisOrder("Viewing data for:",
+ today.minusDays(7).format(dateFmt), " - " + todayStr);
+
+ // Switch to Month view → start = today - 30 days
+ doAndWaitForPageToLoad(() -> selectOptionByValue(viewTypeSelect, "mo"));
+ assertEquals("mo", getUrlParam("viewType"));
+ assertEquals("Month", getSelectedOptionText(viewTypeSelect));
+ assertTextPresentInThisOrder("Viewing data for:",
+ today.minusDays(30).format(dateFmt), " - " + todayStr);
+
+ // --- Path 2: navigate via the Fail/Leak/Hang table on rundown.jsp ---
+ // The link in this table sets `end` to the begin page's selected
+ // date but does not set `viewType`, so the controller defaults to
+ // ViewType.DAY → start = end - 1 day. The link
+ // opens in a new browser tab via target="_blank".
+ beginAt(WebTestHelper.buildRelativeUrl("testresults", PROJECT_NAME, "begin",
+ Map.of("end", "01/17/2026")));
+ Locator failLeakHangLink = Locator.xpath(PROBLEMS_TABLE_XPATH + "//a[text()='TestFailOne']");
+ click(failLeakHangLink);
+ switchToWindow(1); // Link opens in a new tab
+ try
+ {
+ assertTextPresent("TestFailOne");
+ assertTextPresentInThisOrder("Viewing data for:", "01/16/2026", " - 01/17/2026");
+ }
+ finally
+ {
+ getDriver().close(); // Close the tab
+ switchToMainWindow();
+ }
+ }
+
+ @Test
+ public void testShowFlaggedPage()
+ {
+ // Navigate to Flags page — no runs are flagged yet
+ goToProjectHome(PROJECT_NAME);
+ clickAndWait(Locator.linkWithText("Flags"));
+ assertTextPresent("There are currently no flagged runs.");
+
+ // Navigate to a run and flag it
+ navigateToRunById(_cleanRunId);
+ toggleRunFlag();
+
+ // Verify the Flags page now shows the flagged run. Each flagged row is
+ // rendered (in flagged.jsp) as a link with text:
+ // "id: / / "
+ // Match by the runId prefix
+ clickAndWait(Locator.linkWithText("Flags"));
+ assertTextNotPresent("There are currently no flagged runs.");
+ assertTextPresent("Flagged Runs");
+ assertElementPresent(Locator.tag("a").startsWith("id: " + _cleanRunId + " /"));
+
+ // Unflag the run — navigate back to the run detail page
+ navigateToRunById(_cleanRunId);
+ toggleRunFlag();
+
+ // Verify the Flags page is empty again
+ clickAndWait(Locator.linkWithText("Flags"));
+ assertTextPresent("There are currently no flagged runs.");
+ }
+
+ @Test
+ public void testTrainingDataPage()
+ {
+ // The trainingdata.jsp page renders users WITH training runs inside
+ // (each user gets a section header, run
+ // rows with Date | Duration | Tests Run | Failure Count | Mean Memory
+ // | Remove, and a `.stats-row` with "RunCount:N"). Users WITHOUT
+ // training runs are listed in a separate below, under a
+ // "No Training Data --" header
+ Locator.XPathLocator trainingTable = Locator.tagWithId("table", "trainingdata");
+ Locator removeLink = trainingTable.descendant(Locator.tagWithClass("a", "removedata"));
+ Locator statsRow = trainingTable.descendant(Locator.tagWithClass("tr", "stats-row"));
+
+ // Initial state: no run has been added to the training set yet, so
+ // the clean run's date should not appear in the training table and
+ // no Remove link should be present.
+ goToProjectHome(PROJECT_NAME);
+ clickAndWait(Locator.linkWithText("Training Data"));
+ assertElementNotPresent(removeLink);
+ assertElementNotPresent(trainingTable.containing("2026-01-16 06:00"));
+ assertTextPresent(COMPUTER_NAME_1, COMPUTER_NAME_2, "No Training Data --");
+
+ // Add the clean run (posted by COMPUTER_NAME_1) to the training set
+ navigateToRunById(_cleanRunId);
+ assertTextPresent("Add to training set");
+ toggleTrainingSet();
+ assertTextPresent("Remove from training set");
+
+ // Verify the Training Data page now shows the run for COMPUTER_NAME_1.
+ // Scope assertions to
+ clickAndWait(Locator.linkWithText("Training Data"));
+ assertElementPresent(trainingTable.descendant(
+ Locator.tagWithId("tr", "user-anchor-" + COMPUTER_NAME_1)));
+ assertElementPresent(trainingTable.containing(COMPUTER_NAME_1));
+ assertElementPresent(trainingTable.containing("2026-01-16 06:00"));
+ assertElementPresent(removeLink);
+ assertElementPresent(statsRow);
+ assertElementPresent(statsRow.containing("RunCount:1"));
+ // The other computer should still be in the "No Training Data" section
+ assertTextPresent(COMPUTER_NAME_2, "No Training Data --");
+
+ // Remove the run from the training set
+ navigateToRunById(_cleanRunId);
+ assertTextPresent("Remove from training set");
+ toggleTrainingSet();
+ assertTextPresent("Add to training set");
+
+ // After removal: the Remove link and the run's data row are gone, and
+ // the stats row now shows "RunCount:0". TEST-PC-1's section is still
+ // rendered in the training table though, because of a known bug in
+ // TrainRunAction: when the user's last training run is removed, the
+ // the stale UserData row (non-zero meanmemory / meantestsrun) is
+ // never cleared. trainingdata.jsp:158 only moves users to the
+ // "No Training Data --" list when both fields are 0, so the section
+ // stays. See TODO-LK-20260403_testresults-bugs.md.
+ clickAndWait(Locator.linkWithText("Training Data"));
+ assertElementNotPresent(removeLink);
+ assertElementNotPresent(trainingTable.containing("2026-01-16 06:00"));
+ assertElementPresent(statsRow.containing("RunCount:0"));
+ assertTextPresent(COMPUTER_NAME_2, "No Training Data --");
+ }
+
+ @Test
+ public void testViewLog()
+ {
+ // The clean run has a element — ViewLogAction should return it
+ String logContent = getApiString("viewLog", _cleanRunId, "log");
+ assertTrue("ViewLog should return log content", logContent != null && !logContent.isEmpty());
+ // Spot-check the nightly header and test entries from the beginning,
+ // middle, and end of the log (see pc1-run-0115-clean.xml).
+ assertTrue("Log should contain nightly header",
+ logContent.contains("# Nightly started Thursday, January 15, 2026 9:00 PM"));
+ assertTrue("Log should contain TestAlpha", logContent.contains("TestAlpha"));
+ assertTrue("Log should contain TestBeta", logContent.contains("TestBeta"));
+ assertTrue("Log should contain TestEpsilon", logContent.contains("TestEpsilon"));
+ assertTrue("Log should contain Test075", logContent.contains("Test075"));
+ assertTrue("Log should contain Test150", logContent.contains("Test150"));
+ }
+
+ @Test
+ public void testViewXml()
+ {
+ // ViewXmlAction should return the stored XML (without the element)
+ String xmlContent = getApiString("viewXml", _cleanRunId, "xml");
+ assertTrue("ViewXml should return XML content", xmlContent != null && !xmlContent.isEmpty());
+ assertTrue("XML should contain nightly element", xmlContent.contains("nightly"));
+ assertTrue("XML should contain test data", xmlContent.contains("TestAlpha"));
+ assertFalse("XML should not contain Log element (stripped before storage)", xmlContent.contains(""));
+ }
+
+ @Test
+ public void testChangeBoundaries()
+ {
+ // Navigate to Training Data page and select the Error/Warning edits action
+ goToProjectHome(PROJECT_NAME);
+ clickAndWait(Locator.linkWithText("Training Data"));
+ selectOptionByValue(Locator.id("actionform"), "error");
+ waitForElement(Locator.id("warningb"));
+
+ // Set warning and error boundaries to custom values
+ setFormElement(Locator.id("warningb"), "2");
+ setFormElement(Locator.id("errorb"), "3");
+ click(Locator.id("submit-button"));
+ waitForText("success!");
+
+ // Empty warning boundary → server rejects (parsed as null Integer)
+ setFormElement(Locator.id("warningb"), "");
+ setFormElement(Locator.id("errorb"), "3");
+ click(Locator.id("submit-button"));
+ waitForText("fail: warning boundary must be a number");
+
+ // Empty error boundary → server rejects (parsed as null Integer)
+ setFormElement(Locator.id("warningb"), "2");
+ setFormElement(Locator.id("errorb"), "");
+ click(Locator.id("submit-button"));
+ waitForText("fail: error boundary must be a number");
+
+ // Set back to defaults
+ setFormElement(Locator.id("warningb"), "1");
+ setFormElement(Locator.id("errorb"), "2");
+ click(Locator.id("submit-button"));
+ waitForText("success!");
+ }
+
+ @Test
+ public void testSetUserActive()
+ {
+ // Add the clean run to the training set so the user appears with activate/deactivate buttons.
+ // The userdata.active column defaults to FALSE, so the user starts inactive.
+ navigateToRunById(_cleanRunId);
+ toggleTrainingSet();
+ assertTextPresent("Remove from training set");
+
+ try
+ {
+ Locator activateButton = Locator.css("input.activate-user");
+ Locator deactivateButton = Locator.css("input.deactivate-user");
+
+ // Navigate to Training Data page — user should have "Activate user" button (inactive by default)
+ clickAndWait(Locator.linkWithText("Training Data"));
+ assertElementPresent(activateButton);
+
+ // Click to activate — AJAX call followed by location.reload()
+ click(activateButton);
+ waitForElement(deactivateButton);
+
+ // Click to deactivate — AJAX call followed by location.reload()
+ click(deactivateButton);
+ waitForElement(activateButton);
+ }
+ finally
+ {
+ // Always clean up: remove the run from the training set
+ navigateToRunById(_cleanRunId);
+ toggleTrainingSet();
+ assertTextPresent("Add to training set");
+ }
+ }
+
+ @Test
+ public void testApiErrorResponses()
+ {
+ // TrainRunAction: missing runId
+ JSONObject noRunId = postApi("trainRun", Map.of("train", "true"));
+ assertFalse(noRunId.optBoolean("Success", true));
+ assertEquals("runId is required", noRunId.optString("error"));
+
+ // TrainRunAction: invalid train value
+ JSONObject badTrain = postApi("trainRun",
+ Map.of("runId", String.valueOf(_cleanRunId), "train", "garbage"));
+ assertFalse(badTrain.optBoolean("Success", true));
+ assertEquals("train must be one of: true, false, force", badTrain.optString("error"));
+
+ // TrainRunAction: nonexistent runId
+ JSONObject missingRun = postApi("trainRun",
+ Map.of("runId", "999999", "train", "true"));
+ assertFalse(missingRun.optBoolean("Success", true));
+ assertEquals("run does not exist: 999999", missingRun.optString("error"));
+
+ // SetUserActive: missing userId
+ JSONObject noUserId = postApi("setUserActive", Map.of("active", "true"));
+ assertEquals("userId is required", noUserId.optString("Message"));
+
+ // SetUserActive: missing active
+ JSONObject noActive = postApi("setUserActive", Map.of("userId", "1"));
+ assertEquals("active parameter is required (true to activate, false to deactivate)",
+ noActive.optString("Message"));
+
+ // DeleteRunAction: missing runId
+ JSONObject noDeleteRunId = postApi("deleteRun", Map.of());
+ assertFalse(noDeleteRunId.optBoolean("Success", true));
+ assertEquals("runId is required", noDeleteRunId.optString("error"));
+
+ // FlagRunAction: missing runId
+ JSONObject noFlagRunId = postApi("flagRun", Map.of("flag", "true"));
+ assertFalse(noFlagRunId.optBoolean("Success", true));
+ assertEquals("runId is required", noFlagRunId.optString("error"));
+ }
+
+ @Test
+ public void testInvalidDateParameters()
+ {
+ // BeginAction (RunDownForm) — invalid end date
+ beginAt(WebTestHelper.buildRelativeUrl("testresults", PROJECT_NAME, "begin",
+ Map.of("end", "garbage")));
+ assertTextPresent("Invalid date format: garbage (expected MM/dd/yyyy)");
+
+ // ShowUserAction — invalid start date
+ beginAt(WebTestHelper.buildRelativeUrl("testresults", PROJECT_NAME, "showUser",
+ Map.of("username", COMPUTER_NAME_1, "start", "not-a-date", "end", "01/17/2026")));
+ assertTextPresent("Invalid start date format: not-a-date (expected MM/dd/yyyy)");
+
+ // ShowUserAction — invalid end date
+ beginAt(WebTestHelper.buildRelativeUrl("testresults", PROJECT_NAME, "showUser",
+ Map.of("username", COMPUTER_NAME_1, "start", "01/15/2026", "end", "bogus")));
+ assertTextPresent("Invalid end date format: bogus (expected MM/dd/yyyy)");
+
+ // ShowFailures (ShowFailuresForm) — invalid end date
+ beginAt(WebTestHelper.buildRelativeUrl("testresults", PROJECT_NAME, "showFailures",
+ Map.of("end", "03-24-2026")));
+ assertTextPresent("Invalid date format: 03-24-2026 (expected MM/dd/yyyy)");
+ }
+
+ @Test
+ public void testDeleteRun()
+ {
+ // Verify the disposable run exists
+ navigateToRunById(_disposableRunId);
+ assertTextPresent(COMPUTER_NAME_1, "TestDisposableOne");
+
+ // Delete it via the Delete Run button on the run detail page
+ click(Locator.id("deleteRun"));
+ acceptAlert();
+
+ // AJAX delete followed by location.reload() — page reloads with deleted runId,
+ // showing the "enter run ID" form since the bean is null
+ waitForElement(Locator.css("input[name='runId']"));
+ assertTextNotPresent("TestDisposableOne");
+
+ // Re-submit the deleted run ID — the form should reappear (bean is
+ // still null) and the run's content should still not be visible.
+ setFormElement(Locator.name("runId"), String.valueOf(_disposableRunId));
+ clickAndWait(SUBMIT_BUTTON);
+ assertElementPresent(Locator.css("input[name='runId']"));
+ assertTextNotPresent("TestDisposableOne");
+ }
+
+ // -------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------
+
+ /**
+ * Navigates to a run detail page via the Run tab by entering the run ID
+ * in the form and clicking Submit.
+ */
+ private void navigateToRunById(int runId)
+ {
+ goToProjectHome(PROJECT_NAME);
+ clickAndWait(Locator.linkWithText("Run"));
+ setFormElement(Locator.name("runId"), String.valueOf(runId));
+ clickAndWait(SUBMIT_BUTTON);
+ }
+
+ /**
+ * Clicks the flag toggle image on the run detail page, accepts the confirmation
+ * dialog, and waits for the page to reload.
+ */
+ private void toggleRunFlag()
+ {
+ Locator.XPathLocator flagImage = Locator.tag("img").withAttribute("id", "flagged");
+ boolean wasFlagged = getAttribute(flagImage, "title").contains("unflag");
+ click(flagImage);
+ acceptAlert();
+ // Wait for the page to reload with the toggled flag state
+ String expectedTitle = wasFlagged ? "Click to flag run" : "Click to unflag run";
+ waitForElement(flagImage.withAttribute("title", expectedTitle));
+ }
+
+ /**
+ * Clicks the "Add to training set" / "Remove from training set" link on the
+ * run detail page and waits for the page to reload.
+ */
+ private void toggleTrainingSet()
+ {
+ Locator trainLink = Locator.id("trainset");
+ String expectedText = getText(trainLink).contains("Add") ? "Remove from training set" : "Add to training set";
+ click(trainLink);
+ waitForText(expectedText);
+ }
+
+ /**
+ * Navigates to the user page, selects the test user, and sets the date range
+ * covering all sample runs. End date is 01/19 because ShowUserAction uses
+ * DateUtils.ceiling (midnight), and runs post at 6:00 AM.
+ */
+ private void navigateToUserPageWithDateRange()
+ {
+ goToProjectHome(PROJECT_NAME);
+ clickAndWait(Locator.linkWithText("User"));
+ Locator usersSelect = Locator.id("users");
+ doAndWaitForPageToLoad(() -> selectOptionByValue(usersSelect, COMPUTER_NAME_1));
+ setDateRange("01/15/2026", "01/19/2026");
+ }
+
+ /**
+ * Sets the date range on the user page by typing into the multi-date range
+ * picker input and clicking "Done". The Done button triggers paramRedirect()
+ * which navigates to the page with the new date range.
+ */
+ private void setDateRange(String startDate, String endDate)
+ {
+ Locator dateInput = Locator.css("#jrange input");
+ setFormElement(dateInput, startDate + " - " + endDate);
+
+ // Focus the input to open the datepicker, then click Done
+ click(dateInput);
+ waitForElement(Locator.tagWithClass("button", "ui-datepicker-close"));
+ clickAndWait(Locator.tagWithClass("button", "ui-datepicker-close"));
+ }
+
+ /**
+ * Asserts that the problems matrix table is present and its header contains
+ * columns for both expected computers.
+ */
+ private void assertProblemsMatrixPresent(String... computerNames)
+ {
+ assertElementPresent(Locator.xpath(PROBLEMS_TABLE_XPATH));
+ for (String name : computerNames)
+ {
+ assertElementPresent(Locator.xpath(PROBLEMS_TABLE_XPATH +
+ "//thead//a[contains(text(),'" + name + "')]"));
+ }
+ }
+
+ /**
+ * Asserts that a test's row in the problems matrix has the expected number
+ * of icons (e.g. fail.png or leak.png). This verifies which computers are
+ * affected: 2 icons means both PCs, 1 icon means only one PC.
+ */
+ private void assertProblemIconCount(String testName, String iconFile, int expectedCount)
+ {
+ Locator icons = Locator.xpath(PROBLEMS_TABLE_XPATH +
+ "//tr[.//a[text()='" + testName + "']]//img[contains(@src,'" + iconFile + "')]");
+ assertEquals("Expected " + expectedCount + " " + iconFile + " icon(s) for " + testName,
+ expectedCount, icons.findElements(getDriver()).size());
+ }
+
+ /**
+ * Asserts that a test name appears in the specified summary table ("Top Failures"
+ * or "Top Leaks") with the expected occurrence count.
+ */
+ private void assertTopSummaryEntry(String tableHeader, String testName, int expectedOccurrences)
+ {
+ Locator occurrenceTd = Locator.xpath(
+ "//table[contains(@class,'decoratedtable')][.//h4[text()='" + tableHeader + "']]" +
+ "//tr[.//a[text()='" + testName + "']]/td[2]");
+ assertEquals(tableHeader + " occurrence count for " + testName,
+ String.valueOf(expectedOccurrences), getText(occurrenceTd).trim());
+ }
+
+ /**
+ * Asserts that the "Mean Leak" column in the Top Leaks table contains the
+ * expected value (e.g. "3 kb" or "5 handles") for a given test.
+ */
+ private void assertTopLeakMean(String testName, String expectedMeanLeak)
+ {
+ Locator meanLeakTd = Locator.xpath(
+ "//table[contains(@class,'decoratedtable')][.//h4[text()='Top Leaks']]" +
+ "//tr[.//a[text()='" + testName + "']]/td[3]");
+ String actual = getText(meanLeakTd).trim();
+ assertTrue("Mean leak for " + testName + " should contain '" + expectedMeanLeak + "' but was '" + actual + "'",
+ actual.contains(expectedMeanLeak));
+ }
+
+ /**
+ * Asserts that the test names appear in the expected order within the test passes
+ * table (the "decoratedtable" whose first cell contains "Test | Sort by:").
+ */
+ private void assertTestPassesSortedAs(String... expectedTestNames)
+ {
+ Locator testPassesTable = Locator.xpath(
+ "//table[contains(@class,'decoratedtable')]" +
+ "[.//tr[1]/td[1][contains(text(),'Test | Sort by:')]]");
+ String tableText = getText(testPassesTable);
+ assertTextPresentInThisOrder(new TextSearcher(tableText), expectedTestNames);
+ }
+
+ /**
+ * Opens the jQuery UI datepicker on the begin page and verifies it is displaying
+ * the expected month, year, and selected day.
+ */
+ private void verifyDateInDatepicker(int month, int day, int year)
+ {
+ String expected = String.format("%02d/%02d/%04d", month, day, year);
+ assertEquals("Datepicker date", expected, getFormElement(Locator.id("datepicker")));
+ }
+
+ // The "<<<" and ">>>" links are element siblings of the #datepicker input
+ // (a previous-day link before and a next-day link after).
+ private static final Locator PREV_DAY_LINK = Locator.xpath(
+ "//a[normalize-space(text())='<<<' and following-sibling::input[@id='datepicker']]");
+ private static final Locator NEXT_DAY_LINK = Locator.xpath(
+ "//a[normalize-space(text())='>>>' and preceding-sibling::input[@id='datepicker']]");
+
+ /**
+ * Clicks the ">>>" link next to the date field {@code count} times to advance
+ * one day per click. Each click triggers a page navigation.
+ */
+ private void goToNextDay(int count)
+ {
+ for (int i = 0; i < count; i++)
+ clickAndWait(NEXT_DAY_LINK);
+ }
+
+ /**
+ * Clicks the "<<<" link next to the date field {@code count} times to go back
+ * one day per click. Each click triggers a page navigation.
+ */
+ private void goToPrevDay(int count)
+ {
+ for (int i = 0; i < count; i++)
+ clickAndWait(PREV_DAY_LINK);
+ }
+
+ /**
+ * Makes an API GET request to a testresults action and returns the value
+ * of the specified field from the JSON response.
+ */
+ private String getApiString(String action, int runId, String field)
+ {
+ String url = WebTestHelper.buildURL("testresults", PROJECT_NAME, action) + "?runId=" + runId;
+ try (CloseableHttpClient httpClient = WebTestHelper.getHttpClient())
+ {
+ HttpGet request = new HttpGet(url);
+ APITestHelper.injectCookies(request);
+ return httpClient.execute(request, response -> {
+ JSONObject json = new JSONObject(EntityUtils.toString(response.getEntity()));
+ return json.optString(field, null);
+ });
+ }
+ catch (Exception e)
+ {
+ throw new RuntimeException("API call failed: " + action, e);
+ }
+ }
+
+ /**
+ * Makes an API POST request with the given query-string parameters and returns
+ * the parsed JSON response. Used to exercise MutatingApiAction error paths.
+ */
+ private JSONObject postApi(String action, Map params)
+ {
+ StringBuilder url = new StringBuilder(WebTestHelper.buildURL("testresults", PROJECT_NAME, action));
+ boolean first = true;
+ for (Map.Entry e : params.entrySet())
+ {
+ url.append(first ? '?' : '&').append(e.getKey()).append('=').append(e.getValue());
+ first = false;
+ }
+ try (CloseableHttpClient httpClient = WebTestHelper.getHttpClient())
+ {
+ HttpPost request = new HttpPost(url.toString());
+ APITestHelper.injectCookies(request);
+ return httpClient.execute(request, response ->
+ new JSONObject(EntityUtils.toString(response.getEntity())));
+ }
+ catch (Exception e)
+ {
+ throw new RuntimeException("API call failed: " + action, e);
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Infrastructure
+ // -------------------------------------------------------------------------
+
+ @Override
+ protected String getProjectName()
+ {
+ return PROJECT_NAME;
+ }
+
+ @Override
+ protected void doCleanup(boolean afterTest)
+ {
+ new APIContainerHelper(this).deleteProject(PROJECT_NAME, afterTest);
+ }
+
+ @Override
+ public List getAssociatedModules()
+ {
+ return List.of("testresults");
+ }
+
+ @Override
+ protected BrowserType bestBrowser()
+ {
+ return BrowserType.CHROME;
+ }
+}