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..458d8966 100644 --- a/testresults/src/org/labkey/testresults/TestResultsController.java +++ b/testresults/src/org/labkey/testresults/TestResultsController.java @@ -18,6 +18,7 @@ import jakarta.servlet.http.HttpServletRequest; import org.apache.commons.io.FileUtils; import org.apache.commons.lang3.ArrayUtils; +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 +27,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; @@ -81,7 +83,7 @@ import org.quartz.Trigger; import org.quartz.TriggerBuilder; import org.quartz.impl.StdSchedulerFactory; -import org.springframework.util.StringUtils; +import org.apache.commons.lang3.StringUtils; import org.springframework.validation.BindException; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartRequest; @@ -92,14 +94,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; @@ -160,6 +166,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 +211,70 @@ 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.setTitle("Test Results"); + return view; } @Override - public void addNavTrail(NavTree root) { } + public void addNavTrail(NavTree root) + { + root.addChild("Test Results"); + } + } + + 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 +298,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 +466,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.setTitle("Training Data"); + return view; } @Override - public void addNavTrail(NavTree root) { } + public void addNavTrail(NavTree root) + { + root.addChild("Training Data"); + } } // 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")) + if (form.getRunId() == null) { - train = true; + return new ApiSimpleResponse(Map.of("Success", false, "error", "runId is required")); } - else if (trainString.equalsIgnoreCase("false")) + 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(Map.of("Success", false, "error", "train must be one of: true, false, force")); } - else if (trainString.equalsIgnoreCase("force")) - { - force = true; - } - else - { - return new ApiSimpleResponse("Success", false); // invalid train value - } + 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 +511,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("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("Success", false, "error", "no action necessary")); } DbScope scope = TestResultsSchema.getSchema().getScope(); try (DbScope.Transaction transaction = scope.ensureTransaction()) @@ -501,30 +554,63 @@ else if ((train && !foundRuns.isEmpty()) || (!train && foundRuns.isEmpty())) } } + 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 +623,64 @@ 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); + JspView view = new JspView<>("/org/labkey/testresults/view/user.jsp", bean); + view.setTitle("User Results"); + return view; } @Override public void addNavTrail(NavTree root) { + root.addChild("User Results"); + } + } + + 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 +689,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.setTitle("Run Detail"); + 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 +722,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.setTitle("Run Detail"); + 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.setTitle("Run Detail"); + return errorView; + } if (filterTestPassesBy != null) { if (filterTestPassesBy.equals("duration")) { List filteredPasses = Arrays.asList(passes); @@ -611,12 +758,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.setTitle("Run Detail"); + return view; } @Override public void addNavTrail(NavTree root) { + root.addChild("Run Detail"); + } + } + + 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 +798,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 +819,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.setTitle("Long-Term Trends"); + return view; } @Override public void addNavTrail(NavTree root) { + root.addChild("Long-Term Trends"); + } + } + + public static class LongTermForm + { + private String _viewType; + + public String getViewType() + { + return _viewType; + } + public void setViewType(String viewType) + { + _viewType = viewType; } } @@ -661,17 +851,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 +891,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.setTitle("Failure Detail"); + 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.setTitle("All Failures"); + return view; } @Override public void addNavTrail(NavTree root) { + root.addChild("Test Failures"); + } + } + + 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 +949,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("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 +972,88 @@ 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("Success", false); response.put("error", x.getMessage()); return response; } - response.put("success", true); + response.put("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("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 + 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("Success", false); response.put("error", x.getMessage()); return response; } - response.put("success", true); + response.put("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 +1066,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.setTitle("Flagged Runs"); + return view; } @Override public void addNavTrail(NavTree root) { + root.addChild("Flagged Runs"); } } @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 +1131,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 +1176,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); @@ -891,10 +1197,10 @@ public Object execute(Object o, BindException errors) } @RequiresNoPermission - public static class SendEmailNotificationAction extends ReadOnlyApiAction + 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 +1211,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 +1229,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 +1316,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 +1324,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 +1380,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 +1481,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 +1591,7 @@ public Object execute(RetrainAllForm form, BindException errors) transaction.commit(); ApiSimpleResponse response = new ApiSimpleResponse(); - response.put("success", true); + response.put("Success", true); response.put("usersRetrained", usersRetrained); response.put("totalTrainRuns", totalTrainRuns); response.put("mode", form.getMode()); @@ -1190,7 +1601,7 @@ public Object execute(RetrainAllForm form, BindException errors) { _log.error("Error in RetrainAllAction", e); ApiSimpleResponse response = new ApiSimpleResponse(); - response.put("success", false); + response.put("Success", false); response.put("error", e.getMessage()); return response; } @@ -1485,7 +1896,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 +1948,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 +1992,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..6b3d79ec 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; @@ -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/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..0a59658c 100644 --- a/testresults/src/org/labkey/testresults/view/user.jsp +++ b/testresults/src/org/labkey/testresults/view/user.jsp @@ -51,7 +51,7 @@ HttpServletRequest req = getViewContext().getRequest(); String startDate = req.getParameter("start"); String endDate = req.getParameter("end"); - String user = req.getParameter("user"); + String user = req.getParameter("username"); boolean showSingleUser = user != null && !user.isEmpty(); DateFormat df = new SimpleDateFormat("MM/dd/yyyy"); Date today = new Date(); @@ -228,7 +228,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 +268,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; + } +}