From 2dfe8060996a9e5585b1458fd298f6fe56fb4763 Mon Sep 17 00:00:00 2001 From: Vagisha Sharma Date: Thu, 2 Apr 2026 14:06:36 -0700 Subject: [PATCH 1/9] - Added FK relations in testresuts.xml - Register JQuery CDN for CSS and images - TestResultsSchema extends UserSchema so we don't have to add it as external schema. Add FK lookup columns for tables - Override addNavTrail so we don't get missing page title warnings when running tests - First version of TestResultsTest. Added example XML files. --- testresults/resources/schemas/testresults.xml | 66 +++- .../testresults/TestResultsController.java | 62 +++- .../labkey/testresults/TestResultsModule.java | 7 + .../labkey/testresults/TestResultsSchema.java | 146 ++++++-- .../labkey/testresults/view/failureDetail.jsp | 6 +- .../test/sampledata/testresults/clean-run.xml | 13 + .../testresults/run-with-failures.xml | 22 ++ .../sampledata/testresults/run-with-leaks.xml | 16 + .../tests/testresults/TestResultsTest.java | 339 ++++++++++++++++++ 9 files changed, 615 insertions(+), 62 deletions(-) create mode 100644 testresults/test/sampledata/testresults/clean-run.xml create mode 100644 testresults/test/sampledata/testresults/run-with-failures.xml create mode 100644 testresults/test/sampledata/testresults/run-with-leaks.xml create mode 100644 testresults/test/src/org/labkey/test/tests/testresults/TestResultsTest.java 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..650e3604 100644 --- a/testresults/src/org/labkey/testresults/TestResultsController.java +++ b/testresults/src/org/labkey/testresults/TestResultsController.java @@ -206,11 +206,16 @@ public static class BeginAction extends SimpleViewAction public ModelAndView getView(Object o, BindException errors) throws Exception { RunDownBean bean = getRunDownBean(getUser(), getContainer(), getViewContext()); - return new JspView<>("/org/labkey/testresults/view/rundown.jsp", bean); + 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"); + } } // return TestDataBean specifically for rundown.jsp aka the home page of the module @@ -244,7 +249,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,11 +417,13 @@ 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 @@ -537,12 +544,15 @@ 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"); } } @@ -561,7 +571,9 @@ public ModelAndView getView(Object o, BindException errors) throws Exception { runId = Integer.parseInt(getViewContext().getRequest().getParameter("runId")); } catch (Exception e) { - 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; } String filterTestPassesBy = getViewContext().getRequest().getParameter("filter"); @@ -583,10 +595,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 +631,15 @@ 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"); } } @@ -646,12 +669,15 @@ 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"); } } @@ -693,16 +719,21 @@ 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"); } } @@ -778,11 +809,14 @@ 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"); } } 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..2a0d3472 100644 --- a/testresults/src/org/labkey/testresults/TestResultsSchema.java +++ b/testresults/src/org/labkey/testresults/TestResultsSchema.java @@ -16,69 +16,147 @@ 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/view/failureDetail.jsp b/testresults/src/org/labkey/testresults/view/failureDetail.jsp index 6537303b..b8fdb80b 100644 --- a/testresults/src/org/labkey/testresults/view/failureDetail.jsp +++ b/testresults/src/org/labkey/testresults/view/failureDetail.jsp @@ -397,14 +397,10 @@ $(document).ready(function() { widthFixed : true, resizable: true, widgets: ['zebra'], - headers : { "#col-problem": { sorter: false } }, + headers : { 5: { sorter: false } }, cssAsc: "headerSortUp", cssDesc: "headerSortDown", ignoreCase: true, - sortList: [[1, 1]], // initial sort by post time descending - sortAppend: { - 0: [[ 1, 'a' ]] // secondary sort by date ascending - }, theme: 'default' }); }); diff --git a/testresults/test/sampledata/testresults/clean-run.xml b/testresults/test/sampledata/testresults/clean-run.xml new file mode 100644 index 00000000..7c0cdf9a --- /dev/null +++ b/testresults/test/sampledata/testresults/clean-run.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/testresults/test/sampledata/testresults/run-with-failures.xml b/testresults/test/sampledata/testresults/run-with-failures.xml new file mode 100644 index 00000000..f4223efb --- /dev/null +++ b/testresults/test/sampledata/testresults/run-with-failures.xml @@ -0,0 +1,22 @@ + + + + + + + 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/run-with-leaks.xml b/testresults/test/sampledata/testresults/run-with-leaks.xml new file mode 100644 index 00000000..16088226 --- /dev/null +++ b/testresults/test/sampledata/testresults/run-with-leaks.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + 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..b8d303ca --- /dev/null +++ b/testresults/test/src/org/labkey/test/tests/testresults/TestResultsTest.java @@ -0,0 +1,339 @@ +/* + * 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.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.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.PostgresOnlyTest; + +import java.io.File; +import java.time.Month; +import java.time.format.TextStyle; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Selenium tests for the testresults module. + * + * Covers the main view actions and their URL parameter binding: + * BeginAction, ShowRunAction, ShowUserAction, LongTermAction, ShowFailures, + * ShowFlaggedAction, and TrainingDataViewAction. + * + * Run before and after the Spring binding refactor to confirm no regressions. + */ +@Category({External.class, MacCossLabModules.class}) +@BaseWebDriverTest.ClassTimeout(minutes = 10) +public class TestResultsTest extends BaseWebDriverTest implements PostgresOnlyTest +{ + private static final String PROJECT_NAME = "TestResultsTest" + TRICKY_CHARACTERS_FOR_PROJECT_NAMES; + static final String COMPUTER_NAME = "TESTPC-AUTOMATION"; + + // Run IDs populated in @BeforeClass, used across test methods + 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"); + + postXmlFixture("testresults/clean-run.xml"); + postXmlFixture("testresults/run-with-failures.xml"); + postXmlFixture("testresults/run-with-leaks.xml"); + + // All runs in this fresh container are our fixtures, sorted by posttime ascending + List> runs = queryRuns(); + assertEquals("Expected 3 posted runs", 3, runs.size()); + _cleanRunId = (Integer) runs.get(0).get("id"); + _failRunId = (Integer) runs.get(1).get("id"); + _leakRunId = (Integer) runs.get(2).get("id"); + } + + /** + * Posts an XML fixture file to PostAction. + */ + private void postXmlFixture(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()); + assertTrue("PostAction failed for " + xmlFile.getName() + ": " + body, + body.contains("\"Success\" : true")); + return null; + }); + } + catch (Exception e) + { + throw new RuntimeException("Failed to post XML fixture: " + 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", "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); + } + } + + // ------------------------------------------------------------------------- + // Tests + // ------------------------------------------------------------------------- + + @Test + public void testBeginPage() + { + // Navigate to the default begin page + beginAt(WebTestHelper.buildRelativeUrl("testresults", PROJECT_NAME, "begin")); + checkErrors(); + + // Use the datepicker to navigate to a date with fixture data (01/18/2026) + selectDateInDatepicker(1, 18, 2026); + checkErrors(); + assertTextPresent(COMPUTER_NAME); + + // 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")); + checkErrors(); + assertEquals("wk", getUrlParam("viewType")); + assertEquals("Week", getSelectedOptionText(viewTypeSelect)); + + // Select Year + doAndWaitForPageToLoad(() -> selectOptionByValue(viewTypeSelect, "yr")); + checkErrors(); + assertEquals("yr", getUrlParam("viewType")); + assertEquals("Year", getSelectedOptionText(viewTypeSelect)); + + // Select back to Month + doAndWaitForPageToLoad(() -> selectOptionByValue(viewTypeSelect, "mo")); + checkErrors(); + assertEquals("mo", getUrlParam("viewType")); + assertEquals("Month", getSelectedOptionText(viewTypeSelect)); + } + + @Test + public void testShowRunPage() + { + // Clean run — shows passes, no failure or leak tables + beginAt(WebTestHelper.buildRelativeUrl("testresults", PROJECT_NAME, "showRun", + Map.of("runId", _cleanRunId))); + checkErrors(); + assertTextPresent(COMPUTER_NAME, "Passed Tests"); + + // filter parameter binding + for (String filter : List.of("duration", "managed", "total")) + { + beginAt(WebTestHelper.buildRelativeUrl("testresults", PROJECT_NAME, "showRun", + Map.of("runId", _cleanRunId, "filter", filter))); + checkErrors(); + } + + // Run with failures — failure table visible + beginAt(WebTestHelper.buildRelativeUrl("testresults", PROJECT_NAME, "showRun", + Map.of("runId", _failRunId))); + checkErrors(); + assertTextPresent("TestFailOne", "TestFailTwo"); + + // Run with leaks — leak table visible + beginAt(WebTestHelper.buildRelativeUrl("testresults", PROJECT_NAME, "showRun", + Map.of("runId", _leakRunId))); + checkErrors(); + assertTextPresent("TestWithMemoryLeak", "TestWithHandleLeak"); + } + + @Test + public void testShowUserPage() + { + // Navigate to user page without a user, then select from the dropdown + beginAt(WebTestHelper.buildRelativeUrl("testresults", PROJECT_NAME, "showUser")); + checkErrors(); + + Locator usersSelect = Locator.id("users"); + doAndWaitForPageToLoad(() -> selectOptionByValue(usersSelect, COMPUTER_NAME)); + checkErrors(); + assertEquals(COMPUTER_NAME, getUrlParam("user", true)); + assertTextPresent(COMPUTER_NAME); + + // With explicit date range covering all three fixtures + beginAt(WebTestHelper.buildRelativeUrl("testresults", PROJECT_NAME, "showUser", + Map.of("user", COMPUTER_NAME, "start", "01/15/2026", "end", "01/18/2026"))); + checkErrors(); + assertTextPresent(COMPUTER_NAME); + } + + @Test + public void testLongTermPage() + { + for (String viewType : List.of("wk", "mo", "yr")) + { + beginAt(WebTestHelper.buildRelativeUrl("testresults", PROJECT_NAME, "longTerm", + Map.of("viewType", viewType))); + checkErrors(); + } + } + + @Test + public void testShowFailuresPage() + { + // Failure detail for a known failed test — Month view + beginAt(WebTestHelper.buildRelativeUrl("testresults", PROJECT_NAME, "showFailures", + Map.of("failedTest", "TestFailOne", "viewType", "mo"))); + checkErrors(); + assertTextPresent("TestFailOne"); + + // Verify the view type selector shows Month + Locator viewTypeSelect = Locator.id("view-type-combobox"); + assertEquals("Month", getSelectedOptionText(viewTypeSelect)); + + // Switch to Week via the selector and verify + doAndWaitForPageToLoad(() -> selectOptionByValue(viewTypeSelect, "wk")); + checkErrors(); + assertEquals("wk", getUrlParam("viewType")); + assertEquals("Week", getSelectedOptionText(viewTypeSelect)); + } + + @Test + public void testShowFlaggedPage() + { + beginAt(WebTestHelper.buildRelativeUrl("testresults", PROJECT_NAME, "showFlagged")); + checkErrors(); + } + + @Test + public void testTrainingDataPage() + { + beginAt(WebTestHelper.buildRelativeUrl("testresults", PROJECT_NAME, "trainingDataView")); + checkErrors(); + assertTextPresent(COMPUTER_NAME); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Selects a date in the jQuery UI datepicker on the begin page by clicking + * through the calendar widget. Navigates backward from the currently displayed + * month to the target month/year, then clicks the target day. The datepicker's + * onSelect callback triggers a page navigation. + */ + private void selectDateInDatepicker(int month, int day, int year) + { + click(Locator.id("datepicker")); + waitForElement(Locator.tagWithClass("div", "ui-datepicker")); + + // Navigate backward to the target month/year + String targetTitle = Month.of(month).getDisplayName(TextStyle.FULL, Locale.ENGLISH) + " " + year; + Locator titleLoc = Locator.tagWithClass("div", "ui-datepicker-title"); + Locator prevButton = Locator.tagWithClass("a", "ui-datepicker-prev"); + + for (int i = 0; i < 24 && !getText(titleLoc).contains(targetTitle); i++) + { + click(prevButton); + } + + // Click the target day (exclude days from adjacent months) + Locator dayLink = Locator.xpath( + "//div[contains(@class,'ui-datepicker')]" + + "//td[not(contains(@class,'ui-datepicker-other-month'))]/a[text()='" + day + "']"); + clickAndWait(dayLink); + } + + // ------------------------------------------------------------------------- + // 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; + } +} From 669dff82f58f611c689e062e31a3698e093f508e Mon Sep 17 00:00:00 2001 From: Vagisha Sharma Date: Fri, 3 Apr 2026 11:37:14 -0700 Subject: [PATCH 2/9] Updated test --- .../tests/testresults/TestResultsTest.java | 303 +++++++++++++----- 1 file changed, 230 insertions(+), 73 deletions(-) diff --git a/testresults/test/src/org/labkey/test/tests/testresults/TestResultsTest.java b/testresults/test/src/org/labkey/test/tests/testresults/TestResultsTest.java index b8d303ca..f99be16a 100644 --- a/testresults/test/src/org/labkey/test/tests/testresults/TestResultsTest.java +++ b/testresults/test/src/org/labkey/test/tests/testresults/TestResultsTest.java @@ -20,6 +20,7 @@ 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.junit.Before; import org.junit.BeforeClass; import org.junit.Test; import org.junit.experimental.categories.Category; @@ -37,6 +38,7 @@ import org.labkey.test.util.APITestHelper; import org.labkey.test.util.LogMethod; import org.labkey.test.util.PostgresOnlyTest; +import org.labkey.test.util.TextSearcher; import java.io.File; import java.time.Month; @@ -63,6 +65,7 @@ public class TestResultsTest extends BaseWebDriverTest implements PostgresOnlyTe { private static final String PROJECT_NAME = "TestResultsTest" + TRICKY_CHARACTERS_FOR_PROJECT_NAMES; static final String COMPUTER_NAME = "TESTPC-AUTOMATION"; + private static final Locator SUBMIT_BUTTON = Locator.css("input[type='submit'][value='Submit']"); // Run IDs populated in @BeforeClass, used across test methods private static int _cleanRunId = -1; @@ -82,11 +85,11 @@ private void doSetup() _containerHelper.createProject(PROJECT_NAME, null); _containerHelper.enableModule("TestResults"); - postXmlFixture("testresults/clean-run.xml"); - postXmlFixture("testresults/run-with-failures.xml"); - postXmlFixture("testresults/run-with-leaks.xml"); + postSampleXml("testresults/clean-run.xml"); + postSampleXml("testresults/run-with-failures.xml"); + postSampleXml("testresults/run-with-leaks.xml"); - // All runs in this fresh container are our fixtures, sorted by posttime ascending + // All runs in this fresh container are our sample runs, sorted by posttime ascending List> runs = queryRuns(); assertEquals("Expected 3 posted runs", 3, runs.size()); _cleanRunId = (Integer) runs.get(0).get("id"); @@ -95,9 +98,9 @@ private void doSetup() } /** - * Posts an XML fixture file to PostAction. + * Posts a sample XML file to PostAction. */ - private void postXmlFixture(String sampleDataRelativePath) + private void postSampleXml(String sampleDataRelativePath) { File xmlFile = TestFileUtils.getSampleData(sampleDataRelativePath); String postUrl = WebTestHelper.buildURL("testresults", PROJECT_NAME, "post"); @@ -118,7 +121,7 @@ private void postXmlFixture(String sampleDataRelativePath) } catch (Exception e) { - throw new RuntimeException("Failed to post XML fixture: " + sampleDataRelativePath, e); + throw new RuntimeException("Failed to post sample XML:" + sampleDataRelativePath, e); } } @@ -142,6 +145,12 @@ private List> queryRuns() } } + @Before + public void navigateToProject() + { + goToProjectHome(PROJECT_NAME); + } + // ------------------------------------------------------------------------- // Tests // ------------------------------------------------------------------------- @@ -149,14 +158,27 @@ private List> queryRuns() @Test public void testBeginPage() { - // Navigate to the default begin page - beginAt(WebTestHelper.buildRelativeUrl("testresults", PROJECT_NAME, "begin")); - checkErrors(); + // Navigate to the begin page via module menu + goToModule("TestResults"); + + // Navigate to 01/16/2026 — the clean run (started 01/15 at 9 PM) + selectDateInDatepicker(1, 16, 2026); + assertTextPresent(COMPUTER_NAME); + assertTextNotPresent("Top Failures"); + assertTextNotPresent("Top Leaks"); - // Use the datepicker to navigate to a date with fixture data (01/18/2026) - selectDateInDatepicker(1, 18, 2026); - checkErrors(); + // Click ">>>" to advance to 01/17/2026 — the run with 2 failures + clickAndWait(Locator.linkWithText(">>>")); assertTextPresent(COMPUTER_NAME); + assertTextPresent("Top Failures"); + assertTextPresent("TestFailOne", "TestFailTwo"); + assertTextNotPresent("Top Leaks"); + + // Click ">>>" to advance to 01/18/2026 — the run with 2 leaks + clickAndWait(Locator.linkWithText(">>>")); + assertTextPresent(COMPUTER_NAME); + assertTextPresent("Top Leaks"); + assertTextPresent("TestWithMemoryLeak", "TestWithHandleLeak"); // Verify viewType selector defaults to Month Locator viewTypeSelect = Locator.id("viewType"); @@ -164,19 +186,16 @@ public void testBeginPage() // Select Week — verify URL parameter and selector state after page reload doAndWaitForPageToLoad(() -> selectOptionByValue(viewTypeSelect, "wk")); - checkErrors(); assertEquals("wk", getUrlParam("viewType")); assertEquals("Week", getSelectedOptionText(viewTypeSelect)); // Select Year doAndWaitForPageToLoad(() -> selectOptionByValue(viewTypeSelect, "yr")); - checkErrors(); assertEquals("yr", getUrlParam("viewType")); assertEquals("Year", getSelectedOptionText(viewTypeSelect)); // Select back to Month doAndWaitForPageToLoad(() -> selectOptionByValue(viewTypeSelect, "mo")); - checkErrors(); assertEquals("mo", getUrlParam("viewType")); assertEquals("Month", getSelectedOptionText(viewTypeSelect)); } @@ -184,103 +203,241 @@ public void testBeginPage() @Test public void testShowRunPage() { - // Clean run — shows passes, no failure or leak tables - beginAt(WebTestHelper.buildRelativeUrl("testresults", PROJECT_NAME, "showRun", - Map.of("runId", _cleanRunId))); - checkErrors(); - assertTextPresent(COMPUTER_NAME, "Passed Tests"); - - // filter parameter binding - for (String filter : List.of("duration", "managed", "total")) - { - beginAt(WebTestHelper.buildRelativeUrl("testresults", PROJECT_NAME, "showRun", - Map.of("runId", _cleanRunId, "filter", filter))); - checkErrors(); - } + // Navigate to user page with sample data dates to get "run details" links + navigateToUserPageWithDateRange(); - // Run with failures — failure table visible - beginAt(WebTestHelper.buildRelativeUrl("testresults", PROJECT_NAME, "showRun", - Map.of("runId", _failRunId))); - checkErrors(); - assertTextPresent("TestFailOne", "TestFailTwo"); + // Runs are sorted descending by date: row 0 = 01/18 (leaks), row 1 = 01/17 (failures), row 2 = 01/16 (clean) - // Run with leaks — leak table visible - beginAt(WebTestHelper.buildRelativeUrl("testresults", PROJECT_NAME, "showRun", - Map.of("runId", _leakRunId))); - checkErrors(); + // Click the first "run details" link (01/18 — leaks run) + clickAndWait(Locator.linkWithText("run details").index(0)); + assertTextPresent(COMPUTER_NAME, "Passed Tests : 5", "Failures : 0", "Leaks : 2"); assertTextPresent("TestWithMemoryLeak", "TestWithHandleLeak"); + + // Sort by Duration (descending) and verify order in the test passes table + clickAndWait(Locator.linkWithText("Duration")); + assertEquals("duration", getUrlParam("filter")); + assertTestPassesSortedAs("TestWithMemoryLeak", "TestWithHandleLeak", "TestGamma", "TestEpsilon", "TestAlpha"); + + // Sort by Managed Memory (descending) and verify order + clickAndWait(Locator.linkContainingText("Managed Memory")); + assertEquals("managed", getUrlParam("filter")); + assertTestPassesSortedAs("TestWithMemoryLeak", "TestWithHandleLeak", "TestGamma", "TestEpsilon", "TestAlpha"); + + // Sort by Total Memory (descending) and verify order + clickAndWait(Locator.linkContainingText("Total Memory")); + assertEquals("total", getUrlParam("filter")); + assertTestPassesSortedAs("TestWithMemoryLeak", "TestWithHandleLeak", "TestGamma", "TestEpsilon", "TestAlpha"); + + // Navigate to user page again for the failures run + navigateToUserPageWithDateRange(); + clickAndWait(Locator.linkWithText("run details").index(1)); + assertTextPresent(COMPUTER_NAME, "Passed Tests : 5", "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, "Passed Tests : 5", "Failures : 0", "Leaks : 0"); } @Test - public void testShowUserPage() + public void testRunLookup() { - // Navigate to user page without a user, then select from the dropdown - beginAt(WebTestHelper.buildRelativeUrl("testresults", PROJECT_NAME, "showUser")); - checkErrors(); + // Look up the leaks run + navigateToRunById(_leakRunId); + assertTextPresent(COMPUTER_NAME, "Passed Tests : 5", "Failures : 0", "Leaks : 2"); + assertTextPresent("TestWithMemoryLeak", "TestWithHandleLeak"); - Locator usersSelect = Locator.id("users"); - doAndWaitForPageToLoad(() -> selectOptionByValue(usersSelect, COMPUTER_NAME)); - checkErrors(); - assertEquals(COMPUTER_NAME, getUrlParam("user", true)); - assertTextPresent(COMPUTER_NAME); + // Look up the failures run + navigateToRunById(_failRunId); + assertTextPresent(COMPUTER_NAME, "Passed Tests : 5", "Failures : 2", "Leaks : 0"); + assertTextPresent("TestFailOne", "TestFailTwo"); - // With explicit date range covering all three fixtures - beginAt(WebTestHelper.buildRelativeUrl("testresults", PROJECT_NAME, "showUser", - Map.of("user", COMPUTER_NAME, "start", "01/15/2026", "end", "01/18/2026"))); - checkErrors(); - assertTextPresent(COMPUTER_NAME); + // Look up the clean run + navigateToRunById(_cleanRunId); + assertTextPresent(COMPUTER_NAME, "Passed Tests : 5", "Failures : 0", "Leaks : 0"); } @Test public void testLongTermPage() { - for (String viewType : List.of("wk", "mo", "yr")) - { - beginAt(WebTestHelper.buildRelativeUrl("testresults", PROJECT_NAME, "longTerm", - Map.of("viewType", viewType))); - checkErrors(); - } + // Navigate to Long Term page via tab click + goToModule("TestResults"); + 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() { - // Failure detail for a known failed test — Month view - beginAt(WebTestHelper.buildRelativeUrl("testresults", PROJECT_NAME, "showFailures", - Map.of("failedTest", "TestFailOne", "viewType", "mo"))); - checkErrors(); + // Navigate to the failures run via the Run tab + navigateToRunById(_failRunId); + + // Click the failure test name link on the run detail page + clickAndWait(Locator.linkWithText("TestFailOne")); assertTextPresent("TestFailOne"); - // Verify the view type selector shows Month + // Verify the view type selector and switch views Locator viewTypeSelect = Locator.id("view-type-combobox"); - assertEquals("Month", getSelectedOptionText(viewTypeSelect)); - - // Switch to Week via the selector and verify - doAndWaitForPageToLoad(() -> selectOptionByValue(viewTypeSelect, "wk")); - checkErrors(); - assertEquals("wk", getUrlParam("viewType")); assertEquals("Week", getSelectedOptionText(viewTypeSelect)); + + doAndWaitForPageToLoad(() -> selectOptionByValue(viewTypeSelect, "mo")); + assertEquals("mo", getUrlParam("viewType")); + assertEquals("Month", getSelectedOptionText(viewTypeSelect)); } @Test public void testShowFlaggedPage() { - beginAt(WebTestHelper.buildRelativeUrl("testresults", PROJECT_NAME, "showFlagged")); - checkErrors(); + // Navigate to Flags page — no runs are flagged yet + goToModule("TestResults"); + 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 + clickAndWait(Locator.linkWithText("Flags")); + assertTextNotPresent("There are currently no flagged runs."); + assertTextPresent("Flagged Runs"); + + // 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() { - beginAt(WebTestHelper.buildRelativeUrl("testresults", PROJECT_NAME, "trainingDataView")); - checkErrors(); + // Navigate to Training Data page — no runs in training set yet + goToModule("TestResults"); + clickAndWait(Locator.linkWithText("Training Data")); + assertTextPresent(COMPUTER_NAME, "No Training Data"); + + // Add the clean run 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 + clickAndWait(Locator.linkWithText("Training Data")); assertTextPresent(COMPUTER_NAME); + assertElementPresent(Locator.css("#trainingdata .removedata")); + + // Remove the run from the training set + navigateToRunById(_cleanRunId); + assertTextPresent("Remove from training set"); + toggleTrainingSet(); + assertTextPresent("Add to training set"); + + // Verify the Training Data page no longer shows training runs + clickAndWait(Locator.linkWithText("Training Data")); + assertTextPresent(COMPUTER_NAME, "No Training Data"); } // ------------------------------------------------------------------------- // 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) + { + goToModule("TestResults"); + 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 flagImage = Locator.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(Locator.xpath("//img[@id='flagged'][@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 three sample runs. + */ + private void navigateToUserPageWithDateRange() + { + goToModule("TestResults"); + clickAndWait(Locator.linkWithText("User")); + Locator usersSelect = Locator.id("users"); + doAndWaitForPageToLoad(() -> selectOptionByValue(usersSelect, COMPUTER_NAME)); + setDateRange("01/15/2026", "01/18/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 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); + } + /** * Selects a date in the jQuery UI datepicker on the begin page by clicking * through the calendar widget. Navigates backward from the currently displayed From 3295d526379288b082bb2989aff3b732b492ec89 Mon Sep 17 00:00:00 2001 From: Vagisha Sharma Date: Sat, 4 Apr 2026 17:45:47 -0700 Subject: [PATCH 3/9] Refactored TestResultsController to use Spring automatic parameter binding - Replaced manual request.getParameter() calls with form classes for all actions that accept parameters. - Use Integer/Boolean instead of primitives for optional params to enable null detection. - Added ParseException handling with SimpleErrorView for date parameters. - Added null guards with descriptive error messages for required parameters. - Added cause field to error API responses and updated JSP alert messages to display it. - Replaced deprecated javax.management.modelmbean.XMLParseException with IllegalArgumentException. - Switched from org.springframework.util.StringUtils to org.apache.commons.lang3.StringUtils. --- .../testresults/TestResultsController.java | 677 ++++++++++++++---- .../testresults/TestResultsWebPart.java | 2 +- .../org/labkey/testresults/view/runDetail.jsp | 6 +- .../org/labkey/testresults/view/rundown.jsp | 2 +- .../labkey/testresults/view/trainingdata.jsp | 4 +- .../src/org/labkey/testresults/view/user.jsp | 2 +- 6 files changed, 541 insertions(+), 152 deletions(-) diff --git a/testresults/src/org/labkey/testresults/TestResultsController.java b/testresults/src/org/labkey/testresults/TestResultsController.java index 650e3604..4d8cea3a 100644 --- a/testresults/src/org/labkey/testresults/TestResultsController.java +++ b/testresults/src/org/labkey/testresults/TestResultsController.java @@ -26,6 +26,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 +82,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,7 +93,6 @@ import org.w3c.dom.NodeList; import org.xml.sax.InputSource; -import javax.management.modelmbean.XMLParseException; import javax.xml.parsers.DocumentBuilder; import java.io.ByteArrayOutputStream; import java.io.DataOutputStream; @@ -160,6 +160,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 { @@ -168,8 +173,14 @@ public static class RetrainAllForm private int _minRuns = 5; private Integer _targetRuns; // backwards compatibility - public String getMode() { return _mode; } - public void setMode(String mode) { _mode = mode; } + public String getMode() + { + return _mode; + } + public void setMode(String mode) + { + _mode = mode; + } public int getMaxRuns() { @@ -178,15 +189,33 @@ public int getMaxRuns() return _targetRuns; return _maxRuns; } - public void setMaxRuns(int maxRuns) { _maxRuns = maxRuns; } + public void setMaxRuns(int maxRuns) + { + _maxRuns = maxRuns; + } - public int getMinRuns() { return _minRuns; } - public void setMinRuns(int minRuns) { _minRuns = minRuns; } + public int getMinRuns() + { + return _minRuns; + } + public void setMinRuns(int minRuns) + { + _minRuns = minRuns; + } - public Integer getTargetRuns() { return _targetRuns; } - public void setTargetRuns(Integer targetRuns) { _targetRuns = targetRuns; } + public Integer getTargetRuns() + { + return _targetRuns; + } + public void setTargetRuns(Integer targetRuns) + { + _targetRuns = targetRuns; + } - public boolean isIncremental() { return "incremental".equalsIgnoreCase(_mode); } + public boolean isIncremental() + { + return "incremental".equalsIgnoreCase(_mode); + } } public TestResultsController() @@ -200,12 +229,20 @@ 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()); + Date endDate; + try { endDate = form.getEndDate(); } + catch (ParseException e) + { + errors.reject(ERROR_MSG, "Invalid date format: " + form.getEnd()); + 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; @@ -218,14 +255,44 @@ public void addNavTrail(NavTree root) } } + 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); @@ -423,35 +490,26 @@ public ModelAndView getView(Object o, BindException errors) throws Exception } @Override - public void addNavTrail(NavTree root) { root.addChild("Training Data"); } + 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")) - { - train = true; - } - else if (trainString.equalsIgnoreCase("false")) - { - } - else if (trainString.equalsIgnoreCase("force")) - { - force = true; - } - else + if (form.getRunId() == null) { - return new ApiSimpleResponse("Success", false); // invalid train value + return new ApiSimpleResponse(Map.of("Success", false, "cause", "runId is required")); } + int runId = form.getRunId(); + String trainString = form.getTrain(); + boolean train = trainString != null && trainString.equalsIgnoreCase("true"); // true = add to training set, false/null = remove + boolean force = trainString != null && trainString.equalsIgnoreCase("force"); SQLFragment sqlFragment = new SQLFragment(); sqlFragment.append("SELECT * FROM " + TestResultsSchema.getTableInfoTrain() + " WHERE runid = ?"); @@ -465,9 +523,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, "cause", "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, "cause", "no action necessary")); } DbScope scope = TestResultsSchema.getSchema().getScope(); try (DbScope.Transaction transaction = scope.ensureTransaction()) @@ -508,30 +566,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 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.getUser(); + 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()); + return new SimpleErrorView(errors); + } + try { endDate = form.getEndDate(); } + catch (ParseException e) + { + errors.reject(ERROR_MSG, "Invalid end date format: " + form.getEnd()); + 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) @@ -556,26 +647,74 @@ public void addNavTrail(NavTree root) } } + public static class ShowUserForm + { + private String _start; + private String _end; + private String _user; + 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 getUser() + { + return _user; + } + public void setUser(String user) + { + _user = user; + } + public String getDatainclude() + { + return _datainclude; + } + public void setDatainclude(String datainclude) + { + _datainclude = datainclude; + } + } + /** * action to view runDetail.jsp (detail for a single run) * 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) { + // 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); @@ -643,17 +782,40 @@ public void addNavTrail(NavTree root) } } + 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; + } + } + /** * action to view longTerm.jsp * 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); @@ -681,23 +843,45 @@ public void addNavTrail(NavTree root) } } + public static class LongTermForm + { + private String _viewType; + + public String getViewType() + { + return _viewType; + } + public void setViewType(String viewType) + { + _viewType = viewType; + } + } + /** * action to view failureDetail.jsp * accepts parameter failedTest as name of the failed test * 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()); + 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); @@ -737,17 +921,59 @@ public void addNavTrail(NavTree root) } } + 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; + } + } + /** * 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()) { @@ -758,45 +984,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 */ @@ -821,25 +1090,23 @@ public void addNavTrail(NavTree root) } @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"); - - int warningB; - int errorB; + Integer warningB = form.getWarningb(); + Integer errorB = form.getErrorb(); - 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); } @@ -876,18 +1143,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); @@ -901,17 +1188,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); @@ -925,10 +1209,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 @@ -939,14 +1223,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); @@ -958,17 +1241,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<>(); @@ -1013,7 +1328,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); @@ -1021,14 +1336,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; @@ -1077,32 +1392,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) { @@ -1127,6 +1493,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 { @@ -1214,7 +1603,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()); @@ -1224,7 +1613,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; } @@ -1519,7 +1908,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")); } } @@ -1571,9 +1960,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); 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/runDetail.jsp b/testresults/src/org/labkey/testresults/view/runDetail.jsp index 3013117d..2ecb9353 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"); }; @@ -216,7 +216,7 @@ if (leaks.length > 0) { %> if (data.Success) { location.reload(); } else { - alert(data); + alert("Failed to update training set." + (data.cause ? " " + data.cause : "")); } }, "json"); }); diff --git a/testresults/src/org/labkey/testresults/view/rundown.jsp b/testresults/src/org/labkey/testresults/view/rundown.jsp index 0c42692e..cec3277a 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.cause ? " " + data.cause : "")); }, "json"); }); diff --git a/testresults/src/org/labkey/testresults/view/trainingdata.jsp b/testresults/src/org/labkey/testresults/view/trainingdata.jsp index f19abf73..0b060dce 100644 --- a/testresults/src/org/labkey/testresults/view/trainingdata.jsp +++ b/testresults/src/org/labkey/testresults/view/trainingdata.jsp @@ -254,7 +254,7 @@ } return; } - alert("Failure removing run. Contact Yuval"); + alert("Failed to update training set." + (data.cause ? " " + data.cause : "")); }, "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..8b4cb10d 100644 --- a/testresults/src/org/labkey/testresults/view/user.jsp +++ b/testresults/src/org/labkey/testresults/view/user.jsp @@ -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.cause ? " " + data.cause : "")); } }, "json"); }); From 3661ca4c6cdb08427dba6b98b9992033a2bd20f2 Mon Sep 17 00:00:00 2001 From: Vagisha Sharma Date: Mon, 6 Apr 2026 00:29:17 -0700 Subject: [PATCH 4/9] - Fixed XML storage in ParseAndStoreXML: Element.toString() does not serialize DOM content. Replaced with Transformer to properly serialize the XML before compressing and storing in the database. - Renamed "user" URL parameter to "username". "user" is in LabKey's disallowed list (HasAllowBindParameter), so the form field was always null, causing ShowUserAction to ignore the selected user and return all runs. - Added sample data for a second computer. Expanded XML files to 150 tests with realistic timestamps and memory profiles to support trend chart rendering. - Expanded Selenium test coverage --- .../testresults/TestResultsController.java | 30 +- .../org/labkey/testresults/view/runDetail.jsp | 2 +- .../labkey/testresults/view/trainingdata.jsp | 4 +- .../src/org/labkey/testresults/view/user.jsp | 4 +- .../test/sampledata/testresults/clean-run.xml | 13 - .../testresults/pc1-run-0114-disposable.xml | 10 + .../testresults/pc1-run-0115-clean.xml | 312 ++++++++++++++++++ .../testresults/pc1-run-0116-failures.xml | 167 ++++++++++ .../testresults/pc1-run-0117-leaks.xml | 161 +++++++++ .../testresults/pc2-run-0115-clean.xml | 158 +++++++++ .../testresults/pc2-run-0116-failures.xml | 163 +++++++++ .../testresults/pc2-run-0117-leaks.xml | 160 +++++++++ .../testresults/run-with-failures.xml | 22 -- .../sampledata/testresults/run-with-leaks.xml | 16 - .../tests/testresults/TestResultsTest.java | 311 ++++++++++++++--- 15 files changed, 1414 insertions(+), 119 deletions(-) delete mode 100644 testresults/test/sampledata/testresults/clean-run.xml create mode 100644 testresults/test/sampledata/testresults/pc1-run-0114-disposable.xml create mode 100644 testresults/test/sampledata/testresults/pc1-run-0115-clean.xml create mode 100644 testresults/test/sampledata/testresults/pc1-run-0116-failures.xml create mode 100644 testresults/test/sampledata/testresults/pc1-run-0117-leaks.xml create mode 100644 testresults/test/sampledata/testresults/pc2-run-0115-clean.xml create mode 100644 testresults/test/sampledata/testresults/pc2-run-0116-failures.xml create mode 100644 testresults/test/sampledata/testresults/pc2-run-0117-leaks.xml delete mode 100644 testresults/test/sampledata/testresults/run-with-failures.xml delete mode 100644 testresults/test/sampledata/testresults/run-with-leaks.xml diff --git a/testresults/src/org/labkey/testresults/TestResultsController.java b/testresults/src/org/labkey/testresults/TestResultsController.java index 4d8cea3a..ab692aea 100644 --- a/testresults/src/org/labkey/testresults/TestResultsController.java +++ b/testresults/src/org/labkey/testresults/TestResultsController.java @@ -94,12 +94,17 @@ import org.xml.sax.InputSource; 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; @@ -591,7 +596,7 @@ public void setTrain(String 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) @@ -600,7 +605,7 @@ public static class ShowUserAction extends SimpleViewAction @Override public ModelAndView getView(ShowUserForm form, BindException errors) throws Exception { - String userName = form.getUser(); + String userName = form.getUsername(); String dataInclude = form.getDatainclude(); Date startDate; Date endDate; @@ -651,7 +656,7 @@ public static class ShowUserForm { private String _start; private String _end; - private String _user; + private String _username; private String _datainclude; public String getStart() @@ -678,13 +683,13 @@ public Date getEndDate() throws ParseException { return parseDate(_end); } - public String getUser() + public String getUsername() { - return _user; + return _username; } - public void setUser(String user) + public void setUsername(String username) { - _user = user; + _username = username; } public String getDatainclude() { @@ -2004,8 +2009,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/view/runDetail.jsp b/testresults/src/org/labkey/testresults/view/runDetail.jsp index 2ecb9353..05037356 100644 --- a/testresults/src/org/labkey/testresults/view/runDetail.jsp +++ b/testresults/src/org/labkey/testresults/view/runDetail.jsp @@ -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%>
diff --git a/testresults/src/org/labkey/testresults/view/trainingdata.jsp b/testresults/src/org/labkey/testresults/view/trainingdata.jsp index 0b060dce..ab8d819a 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())%> diff --git a/testresults/src/org/labkey/testresults/view/user.jsp b/testresults/src/org/labkey/testresults/view/user.jsp index 8b4cb10d..dadafba2 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()); diff --git a/testresults/test/sampledata/testresults/clean-run.xml b/testresults/test/sampledata/testresults/clean-run.xml deleted file mode 100644 index 7c0cdf9a..00000000 --- a/testresults/test/sampledata/testresults/clean-run.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - 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/sampledata/testresults/run-with-failures.xml b/testresults/test/sampledata/testresults/run-with-failures.xml deleted file mode 100644 index f4223efb..00000000 --- a/testresults/test/sampledata/testresults/run-with-failures.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - 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/run-with-leaks.xml b/testresults/test/sampledata/testresults/run-with-leaks.xml deleted file mode 100644 index 16088226..00000000 --- a/testresults/test/sampledata/testresults/run-with-leaks.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/testresults/test/src/org/labkey/test/tests/testresults/TestResultsTest.java b/testresults/test/src/org/labkey/test/tests/testresults/TestResultsTest.java index f99be16a..2836afe9 100644 --- a/testresults/test/src/org/labkey/test/tests/testresults/TestResultsTest.java +++ b/testresults/test/src/org/labkey/test/tests/testresults/TestResultsTest.java @@ -37,6 +37,7 @@ 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; @@ -50,24 +51,16 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; -/** - * Selenium tests for the testresults module. - * - * Covers the main view actions and their URL parameter binding: - * BeginAction, ShowRunAction, ShowUserAction, LongTermAction, ShowFailures, - * ShowFlaggedAction, and TrainingDataViewAction. - * - * Run before and after the Spring binding refactor to confirm no regressions. - */ @Category({External.class, MacCossLabModules.class}) -@BaseWebDriverTest.ClassTimeout(minutes = 10) +@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 = "TESTPC-AUTOMATION"; - private static final Locator SUBMIT_BUTTON = Locator.css("input[type='submit'][value='Submit']"); + static final String COMPUTER_NAME_1 = "TEST-PC-1"; + static final String COMPUTER_NAME_2 = "TEST-PC-2"; // 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; @@ -84,17 +77,32 @@ 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"); - postSampleXml("testresults/clean-run.xml"); - postSampleXml("testresults/run-with-failures.xml"); - postSampleXml("testresults/run-with-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 + // 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 3 posted runs", 3, runs.size()); - _cleanRunId = (Integer) runs.get(0).get("id"); - _failRunId = (Integer) runs.get(1).get("id"); - _leakRunId = (Integer) runs.get(2).get("id"); + 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"); } /** @@ -135,7 +143,7 @@ private List> queryRuns() Connection connection = WebTestHelper.getRemoteApiConnection(); SelectRowsCommand cmd = new SelectRowsCommand("testresults", "testruns"); cmd.setSorts(List.of(new Sort("posttime"))); - cmd.setColumns(List.of("id", "posttime", "passedtests", "failedtests", "leakedtests")); + cmd.setColumns(List.of("id", "posttime", "userid/username", "passedtests", "failedtests", "leakedtests")); SelectRowsResponse response = cmd.execute(connection, PROJECT_NAME); return response.getRows(); } @@ -155,30 +163,54 @@ public void navigateToProject() // Tests // ------------------------------------------------------------------------- + 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:')]]"; + @Test public void testBeginPage() { - // Navigate to the begin page via module menu - goToModule("TestResults"); - // Navigate to 01/16/2026 — the clean run (started 01/15 at 9 PM) selectDateInDatepicker(1, 16, 2026); - assertTextPresent(COMPUTER_NAME); + assertTextPresent(COMPUTER_NAME_1, COMPUTER_NAME_2); assertTextNotPresent("Top Failures"); assertTextNotPresent("Top Leaks"); - // Click ">>>" to advance to 01/17/2026 — the run with 2 failures + // Click ">>>" to advance to 01/17/2026 — failures on both PCs clickAndWait(Locator.linkWithText(">>>")); - assertTextPresent(COMPUTER_NAME); + assertTextPresent(COMPUTER_NAME_1, COMPUTER_NAME_2); assertTextPresent("Top Failures"); - assertTextPresent("TestFailOne", "TestFailTwo"); assertTextNotPresent("Top Leaks"); - // Click ">>>" to advance to 01/18/2026 — the run with 2 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); + + // Click ">>>" to advance to 01/18/2026 — leaks on both PCs clickAndWait(Locator.linkWithText(">>>")); - assertTextPresent(COMPUTER_NAME); + assertTextPresent(COMPUTER_NAME_1, COMPUTER_NAME_2); + assertTextPresent("Top Failures"); // cumulative — failures from 01/17 still in the view period assertTextPresent("Top Leaks"); - assertTextPresent("TestWithMemoryLeak", "TestWithHandleLeak"); + + // 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"); @@ -210,34 +242,34 @@ public void testShowRunPage() // Click the first "run details" link (01/18 — leaks run) clickAndWait(Locator.linkWithText("run details").index(0)); - assertTextPresent(COMPUTER_NAME, "Passed Tests : 5", "Failures : 0", "Leaks : 2"); + assertTextPresent(COMPUTER_NAME_1, "Passed Tests : 150", "Failures : 0", "Leaks : 2"); assertTextPresent("TestWithMemoryLeak", "TestWithHandleLeak"); - // Sort by Duration (descending) and verify order in the test passes table + // Sort by Duration (descending) and verify the sort parameter is applied clickAndWait(Locator.linkWithText("Duration")); assertEquals("duration", getUrlParam("filter")); - assertTestPassesSortedAs("TestWithMemoryLeak", "TestWithHandleLeak", "TestGamma", "TestEpsilon", "TestAlpha"); - // Sort by Managed Memory (descending) and verify order + // 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("TestWithMemoryLeak", "TestWithHandleLeak", "TestGamma", "TestEpsilon", "TestAlpha"); + assertTestPassesSortedAs("TestWithHandleLeak", "TestAlpha"); - // Sort by Total Memory (descending) and verify order + // Sort by Total Memory (descending) — same ordering principle clickAndWait(Locator.linkContainingText("Total Memory")); assertEquals("total", getUrlParam("filter")); - assertTestPassesSortedAs("TestWithMemoryLeak", "TestWithHandleLeak", "TestGamma", "TestEpsilon", "TestAlpha"); + assertTestPassesSortedAs("TestWithHandleLeak", "TestAlpha"); // Navigate to user page again for the failures run navigateToUserPageWithDateRange(); clickAndWait(Locator.linkWithText("run details").index(1)); - assertTextPresent(COMPUTER_NAME, "Passed Tests : 5", "Failures : 2", "Leaks : 0"); + 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, "Passed Tests : 5", "Failures : 0", "Leaks : 0"); + assertTextPresent(COMPUTER_NAME_1, "Passed Tests : 150", "Failures : 0", "Leaks : 0"); } @Test @@ -245,24 +277,24 @@ public void testRunLookup() { // Look up the leaks run navigateToRunById(_leakRunId); - assertTextPresent(COMPUTER_NAME, "Passed Tests : 5", "Failures : 0", "Leaks : 2"); + assertTextPresent(COMPUTER_NAME_1, "Passed Tests : 150", "Failures : 0", "Leaks : 2"); assertTextPresent("TestWithMemoryLeak", "TestWithHandleLeak"); // Look up the failures run navigateToRunById(_failRunId); - assertTextPresent(COMPUTER_NAME, "Passed Tests : 5", "Failures : 2", "Leaks : 0"); + assertTextPresent(COMPUTER_NAME_1, "Passed Tests : 150", "Failures : 2", "Leaks : 0"); assertTextPresent("TestFailOne", "TestFailTwo"); // Look up the clean run navigateToRunById(_cleanRunId); - assertTextPresent(COMPUTER_NAME, "Passed Tests : 5", "Failures : 0", "Leaks : 0"); + assertTextPresent(COMPUTER_NAME_1, "Passed Tests : 150", "Failures : 0", "Leaks : 0"); } @Test public void testLongTermPage() { // Navigate to Long Term page via tab click - goToModule("TestResults"); + goToProjectHome(PROJECT_NAME); clickAndWait(Locator.linkWithText("Long Term")); // Use the viewType selector to switch between views @@ -301,7 +333,7 @@ public void testShowFailuresPage() public void testShowFlaggedPage() { // Navigate to Flags page — no runs are flagged yet - goToModule("TestResults"); + goToProjectHome(PROJECT_NAME); clickAndWait(Locator.linkWithText("Flags")); assertTextPresent("There are currently no flagged runs."); @@ -327,9 +359,9 @@ public void testShowFlaggedPage() public void testTrainingDataPage() { // Navigate to Training Data page — no runs in training set yet - goToModule("TestResults"); + goToProjectHome(PROJECT_NAME); clickAndWait(Locator.linkWithText("Training Data")); - assertTextPresent(COMPUTER_NAME, "No Training Data"); + assertTextPresent(COMPUTER_NAME_1, COMPUTER_NAME_2, "No Training Data"); // Add the clean run to the training set navigateToRunById(_cleanRunId); @@ -339,7 +371,7 @@ public void testTrainingDataPage() // Verify the Training Data page now shows the run clickAndWait(Locator.linkWithText("Training Data")); - assertTextPresent(COMPUTER_NAME); + assertTextPresent(COMPUTER_NAME_1); assertElementPresent(Locator.css("#trainingdata .removedata")); // Remove the run from the training set @@ -350,7 +382,101 @@ public void testTrainingDataPage() // Verify the Training Data page no longer shows training runs clickAndWait(Locator.linkWithText("Training Data")); - assertTextPresent(COMPUTER_NAME, "No Training Data"); + assertTextPresent(COMPUTER_NAME_1, "No Training Data"); + } + + @Test + public void testViewLog() + { + // The clean run has a element — ViewLogAction should return it + String logContent = getApiString("testresults", "viewLog", _cleanRunId, "log"); + assertTrue("ViewLog should return log content", logContent != null && !logContent.isEmpty()); + assertTrue("Log should contain test names", logContent.contains("TestAlpha")); + } + + @Test + public void testViewXml() + { + // ViewXmlAction should return the stored XML (without the element) + String xmlContent = getApiString("testresults", "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")); + assertTrue("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!"); + + // 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 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"); } // ------------------------------------------------------------------------- @@ -363,7 +489,7 @@ public void testTrainingDataPage() */ private void navigateToRunById(int runId) { - goToModule("TestResults"); + goToProjectHome(PROJECT_NAME); clickAndWait(Locator.linkWithText("Run")); setFormElement(Locator.name("runId"), String.valueOf(runId)); clickAndWait(SUBMIT_BUTTON); @@ -398,15 +524,16 @@ private void toggleTrainingSet() /** * Navigates to the user page, selects the test user, and sets the date range - * covering all three sample runs. + * 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() { - goToModule("TestResults"); + goToProjectHome(PROJECT_NAME); clickAndWait(Locator.linkWithText("User")); Locator usersSelect = Locator.id("users"); - doAndWaitForPageToLoad(() -> selectOptionByValue(usersSelect, COMPUTER_NAME)); - setDateRange("01/15/2026", "01/18/2026"); + doAndWaitForPageToLoad(() -> selectOptionByValue(usersSelect, COMPUTER_NAME_1)); + setDateRange("01/15/2026", "01/19/2026"); } /** @@ -425,6 +552,60 @@ private void setDateRange(String startDate, String endDate) 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, getElementCount(icons)); + } + + /** + * 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:"). @@ -466,6 +647,28 @@ private void selectDateInDatepicker(int month, int day, int year) clickAndWait(dayLink); } + /** + * Makes an API GET request and returns the value of the specified field from the JSON response. + */ + private String getApiString(String controller, String action, int runId, String field) + { + String url = WebTestHelper.buildURL(controller, PROJECT_NAME, action) + "?runId=" + runId; + try (CloseableHttpClient httpClient = WebTestHelper.getHttpClient()) + { + var request = new org.apache.hc.client5.http.classic.methods.HttpGet(url); + APITestHelper.injectCookies(request); + return httpClient.execute(request, response -> { + String body = EntityUtils.toString(response.getEntity()); + org.json.JSONObject json = new org.json.JSONObject(body); + return json.optString(field, null); + }); + } + catch (Exception e) + { + throw new RuntimeException("API call failed: " + action, e); + } + } + // ------------------------------------------------------------------------- // Infrastructure // ------------------------------------------------------------------------- From 7956cd4672adac669e8462d99e8b121bda58ca3b Mon Sep 17 00:00:00 2001 From: Vagisha Sharma Date: Mon, 6 Apr 2026 10:21:36 -0700 Subject: [PATCH 5/9] Removed unintentional indentation change. --- .../testresults/TestResultsController.java | 40 ++++--------------- .../labkey/testresults/TestResultsSchema.java | 15 +++++-- 2 files changed, 19 insertions(+), 36 deletions(-) diff --git a/testresults/src/org/labkey/testresults/TestResultsController.java b/testresults/src/org/labkey/testresults/TestResultsController.java index ab692aea..a2dd04b6 100644 --- a/testresults/src/org/labkey/testresults/TestResultsController.java +++ b/testresults/src/org/labkey/testresults/TestResultsController.java @@ -178,14 +178,8 @@ public static class RetrainAllForm private int _minRuns = 5; private Integer _targetRuns; // backwards compatibility - public String getMode() - { - return _mode; - } - public void setMode(String mode) - { - _mode = mode; - } + public String getMode() { return _mode; } + public void setMode(String mode) { _mode = mode; } public int getMaxRuns() { @@ -194,33 +188,15 @@ public int getMaxRuns() return _targetRuns; return _maxRuns; } - public void setMaxRuns(int maxRuns) - { - _maxRuns = maxRuns; - } + public void setMaxRuns(int maxRuns) { _maxRuns = maxRuns; } - public int getMinRuns() - { - return _minRuns; - } - public void setMinRuns(int minRuns) - { - _minRuns = minRuns; - } + public int getMinRuns() { return _minRuns; } + public void setMinRuns(int minRuns) { _minRuns = minRuns; } - public Integer getTargetRuns() - { - return _targetRuns; - } - public void setTargetRuns(Integer targetRuns) - { - _targetRuns = targetRuns; - } + public Integer getTargetRuns() { return _targetRuns; } + public void setTargetRuns(Integer targetRuns) { _targetRuns = targetRuns; } - public boolean isIncremental() - { - return "incremental".equalsIgnoreCase(_mode); - } + public boolean isIncremental() { return "incremental".equalsIgnoreCase(_mode); } } public TestResultsController() diff --git a/testresults/src/org/labkey/testresults/TestResultsSchema.java b/testresults/src/org/labkey/testresults/TestResultsSchema.java index 2a0d3472..d76d5ae9 100644 --- a/testresults/src/org/labkey/testresults/TestResultsSchema.java +++ b/testresults/src/org/labkey/testresults/TestResultsSchema.java @@ -111,7 +111,6 @@ public static SqlDialect getSqlDialect() /** * 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. @@ -140,9 +139,17 @@ private void resolveSchemaForeignKeys(FilteredTable table, Co @Override public @NotNull Set getTableNames() { - 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); + 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); } // --------------------------------------------------------------------------- From ad177dc2508257262fc711046e3df2f91111e214 Mon Sep 17 00:00:00 2001 From: Vagisha Sharma Date: Mon, 6 Apr 2026 15:23:39 -0700 Subject: [PATCH 6/9] Fixed issues found in Copilot review: - TestResultsTest.postSampleXml: parse JSON and assert Success=true instead of substring-matching the response body - TestResultsTest.testBeginPage: start at 01/17/2026 via URL (avoids iterating the datepicker to find the sample-data dates), then navigate via the begin page's <<< / >>> day links. Added - TrainRunAction: restore explicit validation of the `train` param (true/false/force) that was lost in the form-binding refactor. Uses null-safe Strings.CI.equals. - failureDetail.jsp: tablesorter 2.0.5b silently ignored the "#col-problem" headers key (it requires numeric indices). Look up the column index from the ID at init time. Also restored sortList / sortAppend defaults dropped earlier in this branch. --- .../testresults/TestResultsController.java | 11 ++- .../labkey/testresults/view/failureDetail.jsp | 17 +++- .../tests/testresults/TestResultsTest.java | 80 +++++++++++-------- 3 files changed, 70 insertions(+), 38 deletions(-) diff --git a/testresults/src/org/labkey/testresults/TestResultsController.java b/testresults/src/org/labkey/testresults/TestResultsController.java index a2dd04b6..f37c0a2d 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; @@ -489,8 +490,14 @@ public Object execute(TrainRunForm form, BindException errors) } int runId = form.getRunId(); String trainString = form.getTrain(); - boolean train = trainString != null && trainString.equalsIgnoreCase("true"); // true = add to training set, false/null = remove - boolean force = trainString != null && trainString.equalsIgnoreCase("force"); + if (!Strings.CI.equals(trainString, "true") && + !Strings.CI.equals(trainString, "false") && + !Strings.CI.equals(trainString, "force")) + { + return new ApiSimpleResponse(Map.of("Success", false, "cause", "train must be one of: true, false, force")); + } + boolean train = Strings.CI.equals(trainString, "true"); // true = add to training set, false = remove + boolean force = Strings.CI.equals(trainString, "force"); SQLFragment sqlFragment = new SQLFragment(); sqlFragment.append("SELECT * FROM " + TestResultsSchema.getTableInfoTrain() + " WHERE runid = ?"); diff --git a/testresults/src/org/labkey/testresults/view/failureDetail.jsp b/testresults/src/org/labkey/testresults/view/failureDetail.jsp index b8fdb80b..5f89d7b9 100644 --- a/testresults/src/org/labkey/testresults/view/failureDetail.jsp +++ b/testresults/src/org/labkey/testresults/view/failureDetail.jsp @@ -392,15 +392,28 @@ $(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."); + } $("#failurestatstable").tablesorter({ widthFixed : true, resizable: true, widgets: ['zebra'], - headers : { 5: { sorter: false } }, + headers : headers, cssAsc: "headerSortUp", cssDesc: "headerSortDown", ignoreCase: true, + sortList: [[1, 1]], // initial sort by post time descending + sortAppend: { + 0: [[ 1, 'a' ]] // secondary sort by date ascending + }, theme: 'default' }); }); diff --git a/testresults/test/src/org/labkey/test/tests/testresults/TestResultsTest.java b/testresults/test/src/org/labkey/test/tests/testresults/TestResultsTest.java index 2836afe9..d0f6b2af 100644 --- a/testresults/test/src/org/labkey/test/tests/testresults/TestResultsTest.java +++ b/testresults/test/src/org/labkey/test/tests/testresults/TestResultsTest.java @@ -20,6 +20,7 @@ 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; @@ -42,10 +43,7 @@ import org.labkey.test.util.TextSearcher; import java.io.File; -import java.time.Month; -import java.time.format.TextStyle; import java.util.List; -import java.util.Locale; import java.util.Map; import static org.junit.Assert.assertEquals; @@ -122,8 +120,9 @@ private void postSampleXml(String sampleDataRelativePath) .build()); httpClient.execute(request, response -> { String body = EntityUtils.toString(response.getEntity()); + JSONObject json = new JSONObject(body); assertTrue("PostAction failed for " + xmlFile.getName() + ": " + body, - body.contains("\"Success\" : true")); + json.optBoolean("Success", false)); return null; }); } @@ -172,14 +171,8 @@ public void navigateToProject() @Test public void testBeginPage() { - // Navigate to 01/16/2026 — the clean run (started 01/15 at 9 PM) - selectDateInDatepicker(1, 16, 2026); - assertTextPresent(COMPUTER_NAME_1, COMPUTER_NAME_2); - assertTextNotPresent("Top Failures"); - assertTextNotPresent("Top Leaks"); - - // Click ">>>" to advance to 01/17/2026 — failures on both PCs - clickAndWait(Locator.linkWithText(">>>")); + // 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"); @@ -194,8 +187,18 @@ public void testBeginPage() assertTopSummaryEntry("Top Failures", "TestFailOne", 2); assertTopSummaryEntry("Top Failures", "TestFailTwo", 1); - // Click ">>>" to advance to 01/18/2026 — leaks on both PCs - clickAndWait(Locator.linkWithText(">>>")); + // 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"); @@ -620,31 +623,40 @@ private void assertTestPassesSortedAs(String... expectedTestNames) } /** - * Selects a date in the jQuery UI datepicker on the begin page by clicking - * through the calendar widget. Navigates backward from the currently displayed - * month to the target month/year, then clicks the target day. The datepicker's - * onSelect callback triggers a page navigation. + * Opens the jQuery UI datepicker on the begin page and verifies it is displaying + * the expected month, year, and selected day. */ - private void selectDateInDatepicker(int month, int day, int year) + private void verifyDateInDatepicker(int month, int day, int year) { - click(Locator.id("datepicker")); - waitForElement(Locator.tagWithClass("div", "ui-datepicker")); + String expected = String.format("%02d/%02d/%04d", month, day, year); + assertEquals("Datepicker date", expected, getFormElement(Locator.id("datepicker"))); + } - // Navigate backward to the target month/year - String targetTitle = Month.of(month).getDisplayName(TextStyle.FULL, Locale.ENGLISH) + " " + year; - Locator titleLoc = Locator.tagWithClass("div", "ui-datepicker-title"); - Locator prevButton = Locator.tagWithClass("a", "ui-datepicker-prev"); + // 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']]"); - for (int i = 0; i < 24 && !getText(titleLoc).contains(targetTitle); i++) - { - click(prevButton); - } + /** + * 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); + } - // Click the target day (exclude days from adjacent months) - Locator dayLink = Locator.xpath( - "//div[contains(@class,'ui-datepicker')]" + - "//td[not(contains(@class,'ui-datepicker-other-month'))]/a[text()='" + day + "']"); - clickAndWait(dayLink); + /** + * 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); } /** From e405b087408df23c7e93ac050f7853d71125a580 Mon Sep 17 00:00:00 2001 From: Vagisha Sharma Date: Mon, 6 Apr 2026 18:55:22 -0700 Subject: [PATCH 7/9] - testShowFailuresPage: use assertTextPresentInThisOrder for date range (date-only, DST-safe); add second path via rundown Fail/Leak/Hang matrix - testTrainingDataPage: drop brittle empty-state stats-row assertion - testViewLog: spot-check header and tests from start/middle/end - testDeleteRun: resubmit deleted run ID to confirm it's gone - testChangeBoundaries: add empty-value error-message cases - failureDetail.jsp: skip tablesorter initial sort when tbody is empty - Renamed "cause" to "error" in API response. - Added testApiErrorResponses --- .../testresults/TestResultsController.java | 8 +- .../labkey/testresults/view/failureDetail.jsp | 9 +- .../org/labkey/testresults/view/runDetail.jsp | 2 +- .../org/labkey/testresults/view/rundown.jsp | 2 +- .../labkey/testresults/view/trainingdata.jsp | 2 +- .../src/org/labkey/testresults/view/user.jsp | 2 +- .../tests/testresults/TestResultsTest.java | 214 ++++++++++++++++-- 7 files changed, 212 insertions(+), 27 deletions(-) diff --git a/testresults/src/org/labkey/testresults/TestResultsController.java b/testresults/src/org/labkey/testresults/TestResultsController.java index f37c0a2d..168a460d 100644 --- a/testresults/src/org/labkey/testresults/TestResultsController.java +++ b/testresults/src/org/labkey/testresults/TestResultsController.java @@ -486,7 +486,7 @@ public Object execute(TrainRunForm form, BindException errors) { if (form.getRunId() == null) { - return new ApiSimpleResponse(Map.of("Success", false, "cause", "runId is required")); + return new ApiSimpleResponse(Map.of("Success", false, "error", "runId is required")); } int runId = form.getRunId(); String trainString = form.getTrain(); @@ -494,7 +494,7 @@ public Object execute(TrainRunForm form, BindException errors) !Strings.CI.equals(trainString, "false") && !Strings.CI.equals(trainString, "force")) { - return new ApiSimpleResponse(Map.of("Success", false, "cause", "train must be one of: true, false, force")); + return new ApiSimpleResponse(Map.of("Success", false, "error", "train must be one of: true, false, force")); } boolean train = Strings.CI.equals(trainString, "true"); // true = add to training set, false = remove boolean force = Strings.CI.equals(trainString, "force"); @@ -511,9 +511,9 @@ public Object execute(TrainRunForm form, BindException errors) if (!force) { if (details.length == 0) - return new ApiSimpleResponse(Map.of("Success", false, "cause", "run does not exist: " + runId)); + return new ApiSimpleResponse(Map.of("Success", false, "error", "run does not exist: " + runId)); else if ((train && !foundRuns.isEmpty()) || (!train && foundRuns.isEmpty())) - return new ApiSimpleResponse(Map.of("Success", false, "cause", "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()) diff --git a/testresults/src/org/labkey/testresults/view/failureDetail.jsp b/testresults/src/org/labkey/testresults/view/failureDetail.jsp index 5f89d7b9..3d675525 100644 --- a/testresults/src/org/labkey/testresults/view/failureDetail.jsp +++ b/testresults/src/org/labkey/testresults/view/failureDetail.jsp @@ -402,6 +402,9 @@ $(document).ready(function() { } 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, @@ -410,10 +413,10 @@ $(document).ready(function() { 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 05037356..c4895a38 100644 --- a/testresults/src/org/labkey/testresults/view/runDetail.jsp +++ b/testresults/src/org/labkey/testresults/view/runDetail.jsp @@ -216,7 +216,7 @@ if (leaks.length > 0) { %> if (data.Success) { location.reload(); } else { - alert("Failed to update training set." + (data.cause ? " " + data.cause : "")); + 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 cec3277a..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("Failed to update training set." + (data.cause ? " " + data.cause : "")); + 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 ab8d819a..69d4d5cd 100644 --- a/testresults/src/org/labkey/testresults/view/trainingdata.jsp +++ b/testresults/src/org/labkey/testresults/view/trainingdata.jsp @@ -254,7 +254,7 @@ } return; } - alert("Failed to update training set." + (data.cause ? " " + data.cause : "")); + alert("Failed to update training set." + (data.error ? " " + data.error : "")); }, "json"); }); diff --git a/testresults/src/org/labkey/testresults/view/user.jsp b/testresults/src/org/labkey/testresults/view/user.jsp index dadafba2..0a59658c 100644 --- a/testresults/src/org/labkey/testresults/view/user.jsp +++ b/testresults/src/org/labkey/testresults/view/user.jsp @@ -268,7 +268,7 @@ trainObj.setAttribute("runTrained", !isTrainRun); trainObj.innerHTML = !isTrainRun ? "Remove from training set" : "Add to training set"; } else { - alert("Failed to update training set." + (data.cause ? " " + data.cause : "")); + alert("Failed to update training set." + (data.error ? " " + data.error : "")); } }, "json"); }); diff --git a/testresults/test/src/org/labkey/test/tests/testresults/TestResultsTest.java b/testresults/test/src/org/labkey/test/tests/testresults/TestResultsTest.java index d0f6b2af..22d668b6 100644 --- a/testresults/test/src/org/labkey/test/tests/testresults/TestResultsTest.java +++ b/testresults/test/src/org/labkey/test/tests/testresults/TestResultsTest.java @@ -43,6 +43,8 @@ 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; @@ -316,20 +318,67 @@ public void testLongTermPage() @Test public void testShowFailuresPage() { - // Navigate to the failures run via the Run tab - navigateToRunById(_failRunId); + // 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 year-round. + + // --- 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); - // Click the failure test name link on the run detail page + navigateToRunById(_failRunId); clickAndWait(Locator.linkWithText("TestFailOne")); assertTextPresent("TestFailOne"); - // Verify the view type selector and switch views + // 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 Top Failures table + // link also passes `viewType`, which preserves rundown's current + // view and produces a 30-day window — not what we want here.) + // Anchoring to a fixed sample-data date (01/17/2026) keeps this + // assertion stable regardless of when the test runs. 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( + "//table[contains(@class,'decoratedtable')][.//td[contains(., 'Fail:') and contains(., 'Leak:') and contains(., 'Hang:')]]" + + "//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 @@ -344,10 +393,15 @@ public void testShowFlaggedPage() navigateToRunById(_cleanRunId); toggleRunFlag(); - // Verify the Flags page now shows the flagged run + // 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 — userId and postTime are baked into the + // sample XML and aren't worth duplicating in the test. 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); @@ -361,21 +415,48 @@ public void testShowFlaggedPage() @Test public void testTrainingDataPage() { - // Navigate to Training Data page — no runs in training set yet + // 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 — that header is always present, so + // it can't be used as an empty-state indicator. + 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. (We can't assert the stats-row + // is absent — users may already have non-zero mean memory / mean + // tests-run from the imported sample data, which causes a stats-row + // to render before any run is explicitly added.) goToProjectHome(PROJECT_NAME); clickAndWait(Locator.linkWithText("Training Data")); - assertTextPresent(COMPUTER_NAME_1, COMPUTER_NAME_2, "No 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 to the training set + // 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 + // Verify the Training Data page now shows the run for COMPUTER_NAME_1. + // Scope assertions to
so we don't accidentally + // match the same text in the "No Training Data" section below. clickAndWait(Locator.linkWithText("Training Data")); - assertTextPresent(COMPUTER_NAME_1); - assertElementPresent(Locator.css("#trainingdata .removedata")); + 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); @@ -383,9 +464,17 @@ public void testTrainingDataPage() toggleTrainingSet(); assertTextPresent("Add to training set"); - // Verify the Training Data page no longer shows training runs + // After removal: the Remove link and the run's data row are gone, but + // TEST-PC-1's section stays in the training table because the user's + // meanmemory / meantestsrun are persisted on the User row (not derived + // from current training rows). The stats row now shows "RunCount:0". + // See trainingdata.jsp:158 — users only move to the "No Training Data" + // section when both meanmemory and meantestsrun are 0. clickAndWait(Locator.linkWithText("Training Data")); - assertTextPresent(COMPUTER_NAME_1, "No 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 @@ -394,7 +483,15 @@ public void testViewLog() // The clean run has a element — ViewLogAction should return it String logContent = getApiString("testresults", "viewLog", _cleanRunId, "log"); assertTrue("ViewLog should return log content", logContent != null && !logContent.isEmpty()); - assertTrue("Log should contain test names", logContent.contains("TestAlpha")); + // 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 @@ -423,6 +520,18 @@ public void testChangeBoundaries() 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"); @@ -465,6 +574,46 @@ public void testSetUserActive() } } + @Test + public void testApiErrorResponses() + { + // TrainRunAction: missing runId + JSONObject noRunId = postApi("trainRun", Map.of("train", "true")); + assertEquals(false, 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")); + assertEquals(false, 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")); + assertEquals(false, 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()); + assertEquals(false, noDeleteRunId.optBoolean("Success", true)); + assertEquals("runId is required", noDeleteRunId.optString("error")); + + // FlagRunAction: missing runId + JSONObject noFlagRunId = postApi("flagRun", Map.of("flag", "true")); + assertEquals(false, noFlagRunId.optBoolean("Success", true)); + assertEquals("runId is required", noFlagRunId.optString("error")); + } + @Test public void testDeleteRun() { @@ -480,6 +629,13 @@ public void testDeleteRun() // 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"); } // ------------------------------------------------------------------------- @@ -504,13 +660,13 @@ private void navigateToRunById(int runId) */ private void toggleRunFlag() { - Locator flagImage = Locator.id("flagged"); + 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(Locator.xpath("//img[@id='flagged'][@title='" + expectedTitle + "']")); + waitForElement(flagImage.withAttribute("title", expectedTitle)); } /** @@ -681,6 +837,32 @@ private String getApiString(String controller, String action, int runId, String } } + /** + * 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 // ------------------------------------------------------------------------- From 6e5357e97e6fa3eec36fd2bebfeabda73559ab34 Mon Sep 17 00:00:00 2001 From: Vagisha Sharma Date: Mon, 6 Apr 2026 19:42:58 -0700 Subject: [PATCH 8/9] - Test cleanup - Updated comment for testTrainingDataPage --- .../tests/testresults/TestResultsTest.java | 85 +++++++++---------- 1 file changed, 39 insertions(+), 46 deletions(-) diff --git a/testresults/test/src/org/labkey/test/tests/testresults/TestResultsTest.java b/testresults/test/src/org/labkey/test/tests/testresults/TestResultsTest.java index 22d668b6..7b315fcd 100644 --- a/testresults/test/src/org/labkey/test/tests/testresults/TestResultsTest.java +++ b/testresults/test/src/org/labkey/test/tests/testresults/TestResultsTest.java @@ -15,6 +15,7 @@ */ 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; @@ -49,6 +50,7 @@ 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}) @@ -59,6 +61,13 @@ public class TestResultsTest extends BaseWebDriverTest implements PostgresOnlyTe 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; @@ -164,12 +173,6 @@ public void navigateToProject() // Tests // ------------------------------------------------------------------------- - 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:')]]"; - @Test public void testBeginPage() { @@ -324,7 +327,7 @@ public void testShowFailuresPage() // 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 year-round. + // keeps the test stable. // --- Path 1: navigate via runDetail.jsp --- // The "TestFailOne" link on runDetail.jsp does NOT set the `end` @@ -356,17 +359,11 @@ public void testShowFailuresPage() // --- 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 Top Failures table - // link also passes `viewType`, which preserves rundown's current - // view and produces a 30-day window — not what we want here.) - // Anchoring to a fixed sample-data date (01/17/2026) keeps this - // assertion stable regardless of when the test runs. The link + // 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( - "//table[contains(@class,'decoratedtable')][.//td[contains(., 'Fail:') and contains(., 'Leak:') and contains(., 'Hang:')]]" + - "//a[text()='TestFailOne']"); + Locator failLeakHangLink = Locator.xpath(PROBLEMS_TABLE_XPATH + "//a[text()='TestFailOne']"); click(failLeakHangLink); switchToWindow(1); // Link opens in a new tab try @@ -396,8 +393,7 @@ public void testShowFlaggedPage() // 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 — userId and postTime are baked into the - // sample XML and aren't worth duplicating in the test. + // Match by the runId prefix clickAndWait(Locator.linkWithText("Flags")); assertTextNotPresent("There are currently no flagged runs."); assertTextPresent("Flagged Runs"); @@ -420,18 +416,14 @@ public void testTrainingDataPage() // 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 — that header is always present, so - // it can't be used as an empty-state indicator. + // "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. (We can't assert the stats-row - // is absent — users may already have non-zero mean memory / mean - // tests-run from the imported sample data, which causes a stats-row - // to render before any run is explicitly added.) + // no Remove link should be present. goToProjectHome(PROJECT_NAME); clickAndWait(Locator.linkWithText("Training Data")); assertElementNotPresent(removeLink); @@ -445,8 +437,7 @@ public void testTrainingDataPage() assertTextPresent("Remove from training set"); // Verify the Training Data page now shows the run for COMPUTER_NAME_1. - // Scope assertions to
so we don't accidentally - // match the same text in the "No Training Data" section below. + // Scope assertions to
clickAndWait(Locator.linkWithText("Training Data")); assertElementPresent(trainingTable.descendant( Locator.tagWithId("tr", "user-anchor-" + COMPUTER_NAME_1))); @@ -464,12 +455,14 @@ public void testTrainingDataPage() toggleTrainingSet(); assertTextPresent("Add to training set"); - // After removal: the Remove link and the run's data row are gone, but - // TEST-PC-1's section stays in the training table because the user's - // meanmemory / meantestsrun are persisted on the User row (not derived - // from current training rows). The stats row now shows "RunCount:0". - // See trainingdata.jsp:158 — users only move to the "No Training Data" - // section when both meanmemory and meantestsrun are 0. + // 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")); @@ -481,7 +474,7 @@ public void testTrainingDataPage() public void testViewLog() { // The clean run has a element — ViewLogAction should return it - String logContent = getApiString("testresults", "viewLog", _cleanRunId, "log"); + 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). @@ -498,11 +491,11 @@ public void testViewLog() public void testViewXml() { // ViewXmlAction should return the stored XML (without the element) - String xmlContent = getApiString("testresults", "viewXml", _cleanRunId, "xml"); + 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")); - assertTrue("XML should not contain Log element (stripped before storage)", !xmlContent.contains("")); + assertFalse("XML should not contain Log element (stripped before storage)", xmlContent.contains("")); } @Test @@ -579,19 +572,19 @@ public void testApiErrorResponses() { // TrainRunAction: missing runId JSONObject noRunId = postApi("trainRun", Map.of("train", "true")); - assertEquals(false, noRunId.optBoolean("Success", 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")); - assertEquals(false, badTrain.optBoolean("Success", true)); + 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")); - assertEquals(false, missingRun.optBoolean("Success", true)); + assertFalse(missingRun.optBoolean("Success", true)); assertEquals("run does not exist: 999999", missingRun.optString("error")); // SetUserActive: missing userId @@ -605,12 +598,12 @@ public void testApiErrorResponses() // DeleteRunAction: missing runId JSONObject noDeleteRunId = postApi("deleteRun", Map.of()); - assertEquals(false, noDeleteRunId.optBoolean("Success", true)); + assertFalse(noDeleteRunId.optBoolean("Success", true)); assertEquals("runId is required", noDeleteRunId.optString("error")); // FlagRunAction: missing runId JSONObject noFlagRunId = postApi("flagRun", Map.of("flag", "true")); - assertEquals(false, noFlagRunId.optBoolean("Success", true)); + assertFalse(noFlagRunId.optBoolean("Success", true)); assertEquals("runId is required", noFlagRunId.optString("error")); } @@ -735,7 +728,7 @@ private void assertProblemIconCount(String testName, String iconFile, int expect Locator icons = Locator.xpath(PROBLEMS_TABLE_XPATH + "//tr[.//a[text()='" + testName + "']]//img[contains(@src,'" + iconFile + "')]"); assertEquals("Expected " + expectedCount + " " + iconFile + " icon(s) for " + testName, - expectedCount, getElementCount(icons)); + expectedCount, icons.findElements(getDriver()).size()); } /** @@ -816,18 +809,18 @@ private void goToPrevDay(int count) } /** - * Makes an API GET request and returns the value of the specified field from the JSON response. + * 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 controller, String action, int runId, String field) + private String getApiString(String action, int runId, String field) { - String url = WebTestHelper.buildURL(controller, PROJECT_NAME, action) + "?runId=" + runId; + String url = WebTestHelper.buildURL("testresults", PROJECT_NAME, action) + "?runId=" + runId; try (CloseableHttpClient httpClient = WebTestHelper.getHttpClient()) { - var request = new org.apache.hc.client5.http.classic.methods.HttpGet(url); + HttpGet request = new HttpGet(url); APITestHelper.injectCookies(request); return httpClient.execute(request, response -> { - String body = EntityUtils.toString(response.getEntity()); - org.json.JSONObject json = new org.json.JSONObject(body); + JSONObject json = new JSONObject(EntityUtils.toString(response.getEntity())); return json.optString(field, null); }); } From be0272134ebe20353b97a6a60ed3b201072d38dc Mon Sep 17 00:00:00 2001 From: Vagisha Sharma Date: Mon, 6 Apr 2026 23:24:30 -0700 Subject: [PATCH 9/9] Added testInvalidDateParameters. Include the expected MM/dd/yyyy format in the error message. --- .../testresults/TestResultsController.java | 8 +++---- .../tests/testresults/TestResultsTest.java | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/testresults/src/org/labkey/testresults/TestResultsController.java b/testresults/src/org/labkey/testresults/TestResultsController.java index 168a460d..458d8966 100644 --- a/testresults/src/org/labkey/testresults/TestResultsController.java +++ b/testresults/src/org/labkey/testresults/TestResultsController.java @@ -220,7 +220,7 @@ public ModelAndView getView(RunDownForm form, BindException errors) throws Excep try { endDate = form.getEndDate(); } catch (ParseException e) { - errors.reject(ERROR_MSG, "Invalid date format: " + form.getEnd()); + errors.reject(ERROR_MSG, "Invalid date format: " + form.getEnd() + " (expected MM/dd/yyyy)"); return new SimpleErrorView(errors); } @@ -595,13 +595,13 @@ public ModelAndView getView(ShowUserForm form, BindException errors) throws Exce try { startDate = form.getStartDate(); } catch (ParseException e) { - errors.reject(ERROR_MSG, "Invalid start date format: " + form.getStart()); + 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()); + errors.reject(ERROR_MSG, "Invalid end date format: " + form.getEnd() + " (expected MM/dd/yyyy)"); return new SimpleErrorView(errors); } if (startDate == null) @@ -866,7 +866,7 @@ public ModelAndView getView(ShowFailuresForm form, BindException errors) throws } catch (ParseException e) { - errors.reject(ERROR_MSG, "Invalid date format: " + form.getEnd()); + 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()); diff --git a/testresults/test/src/org/labkey/test/tests/testresults/TestResultsTest.java b/testresults/test/src/org/labkey/test/tests/testresults/TestResultsTest.java index 7b315fcd..28b57a81 100644 --- a/testresults/test/src/org/labkey/test/tests/testresults/TestResultsTest.java +++ b/testresults/test/src/org/labkey/test/tests/testresults/TestResultsTest.java @@ -607,6 +607,30 @@ public void testApiErrorResponses() 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() {