From 2db1aac512a331268cd281c26bb704febf66fd12 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Tue, 15 Sep 2026 09:01:28 -0600 Subject: [PATCH 1/5] Share the data entry validation wait across EHR tests The wait tolerates DataEntryErrorPanel's repaint buffer and re-validates once before failing, and was previously private to one module's test. --- .../test/tests/ehr/AbstractEHRTest.java | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/ehr/test/src/org/labkey/test/tests/ehr/AbstractEHRTest.java b/ehr/test/src/org/labkey/test/tests/ehr/AbstractEHRTest.java index 9840ca203..0cff01725 100644 --- a/ehr/test/src/org/labkey/test/tests/ehr/AbstractEHRTest.java +++ b/ehr/test/src/org/labkey/test/tests/ehr/AbstractEHRTest.java @@ -74,6 +74,9 @@ abstract public class AbstractEHRTest extends BaseWebDriverTest implements Advan protected static final int POPULATE_TIMEOUT_MS = 300000; + // Longest buffer DataEntryErrorPanel puts between a validation event and repainting the error summary + protected static final int ERROR_PANEL_REPAINT_BUFFER = 1500; + public static final String PROJECT_ID = "640991"; // project with one participant public static final String PROJECT_ID_2 = "123456"; public static final String DUMMY_PROTOCOL = "dummyprotocol"; // need a protocol to create table entry @@ -1062,6 +1065,58 @@ public enum EHRQCState } } + /** + * Waits for a validation message to clear, re-running server-side validation once if it does not. A value can be + * accepted at the field while the form's error summary still lists it, which the form itself handles by pointing + * the user at More Actions -> Re-Validate. + */ + protected void waitForValidationToClear(String message) + { + if (waitForValidationToSettleWithout(message)) + return; + + log("Form kept reporting '" + message + "', re-validating"); + revalidateForm(); + if (!waitForValidationToSettleWithout(message)) + Assert.fail("Form kept reporting after re-validating: " + message); + } + + /** + * Waits for the form to go quiet without reporting the given message. DataEntryErrorPanel repaints on a buffered + * event rather than when the validation response lands, so the summary trails the form's actual state by up to a + * second: a message can read as absent before validation has reported it, and read as present after the value + * that raised it was accepted. Neither is worth acting on, so require no validation in flight and the message + * absent, then re-check after the repaint window to confirm the absence survives it. + */ + protected boolean waitForValidationToSettleWithout(String message) + { + return waitFor(() -> { + if (getValidationRequestsInFlight() > 0 || isTextPresent(message)) + return false; + + sleep(ERROR_PANEL_REPAINT_BUFFER); + return getValidationRequestsInFlight() == 0 && !isTextPresent(message); + }, WAIT_FOR_JAVASCRIPT); + } + + // Server validations the form is still waiting on. StoreCollection counts these itself; the form has no + // rendered "validating" state to watch instead. + protected int getValidationRequestsInFlight() + { + Object inFlight = executeScript("var panel = Ext4.ComponentQuery.query('ehr-dataentrypanel')[0];" + + "return panel && panel.storeCollection ? panel.storeCollection.validationRequestsInFlight : 0;"); + + return inFlight == null ? 0 : ((Number) inFlight).intValue(); + } + + // More Actions -> Re-Validate: re-runs server-side validation on every record in the form + protected void revalidateForm() + { + WebElement moreActions = _helper.getDataEntryButton("More Actions").findElement(getDriver()); + scrollIntoView(moreActions); + _ext4Helper.clickExt4MenuButton(false, moreActions, false, "Re-Validate"); + } + protected void setupNotificationService() { //set general settings From af29ac84a973313b3333f935be81781705f4713a Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Tue, 15 Sep 2026 11:49:19 -0600 Subject: [PATCH 2/5] Share the pre-submit wait for the form error summary Routes it through the validation wait so a stale summary re-validates instead of timing out. --- .../src/org/labkey/test/tests/ehr/AbstractEHRTest.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/ehr/test/src/org/labkey/test/tests/ehr/AbstractEHRTest.java b/ehr/test/src/org/labkey/test/tests/ehr/AbstractEHRTest.java index 0cff01725..dd856b3e0 100644 --- a/ehr/test/src/org/labkey/test/tests/ehr/AbstractEHRTest.java +++ b/ehr/test/src/org/labkey/test/tests/ehr/AbstractEHRTest.java @@ -77,6 +77,9 @@ abstract public class AbstractEHRTest extends BaseWebDriverTest implements Advan // Longest buffer DataEntryErrorPanel puts between a validation event and repainting the error summary protected static final int ERROR_PANEL_REPAINT_BUFFER = 1500; + // DataEntryErrorPanel's heading, on screen whenever the form is reporting anything at all + private static final String FORM_ERROR_SUMMARY = "The form has the following errors and warnings:"; + public static final String PROJECT_ID = "640991"; // project with one participant public static final String PROJECT_ID_2 = "123456"; public static final String DUMMY_PROTOCOL = "dummyprotocol"; // need a protocol to create table entry @@ -1065,6 +1068,12 @@ public enum EHRQCState } } + /** Waits for the form to stop reporting anything, so a submit does not race a stale error summary. */ + protected void waitForFormValidationToClear() + { + waitForValidationToClear(FORM_ERROR_SUMMARY); + } + /** * Waits for a validation message to clear, re-running server-side validation once if it does not. A value can be * accepted at the field while the form's error summary still lists it, which the form itself handles by pointing From 7292759e4c692b57175fb10361934b913aa4ffb6 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Tue, 15 Sep 2026 19:28:07 -0600 Subject: [PATCH 3/5] Report no validation in flight when Ext4 is not on the page Callers reach the probe mid-navigation, where referencing Ext4 threw and failed the test outright. --- ehr/test/src/org/labkey/test/tests/ehr/AbstractEHRTest.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ehr/test/src/org/labkey/test/tests/ehr/AbstractEHRTest.java b/ehr/test/src/org/labkey/test/tests/ehr/AbstractEHRTest.java index dd856b3e0..1b3e47cb8 100644 --- a/ehr/test/src/org/labkey/test/tests/ehr/AbstractEHRTest.java +++ b/ehr/test/src/org/labkey/test/tests/ehr/AbstractEHRTest.java @@ -1109,10 +1109,12 @@ protected boolean waitForValidationToSettleWithout(String message) } // Server validations the form is still waiting on. StoreCollection counts these itself; the form has no - // rendered "validating" state to watch instead. + // rendered "validating" state to watch instead. Callers reach this mid-navigation, before Ext4 has loaded, so + // referencing it unguarded throws rather than reporting the nothing that is actually in flight. protected int getValidationRequestsInFlight() { - Object inFlight = executeScript("var panel = Ext4.ComponentQuery.query('ehr-dataentrypanel')[0];" + + Object inFlight = executeScript("if (typeof Ext4 === 'undefined') return 0;" + + "var panel = Ext4.ComponentQuery.query('ehr-dataentrypanel')[0];" + "return panel && panel.storeCollection ? panel.storeCollection.validationRequestsInFlight : 0;"); return inFlight == null ? 0 : ((Number) inFlight).intValue(); From 675ed85f79fa7474332dcde5644a78dfd2f7d3ae Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Wed, 16 Sep 2026 07:25:54 -0600 Subject: [PATCH 4/5] Fail on the message that would not clear, not on a missing Re-Validate Forms that do not offer Re-Validate now report that instead of throwing, and the pre-submit wait keeps the 30 seconds it had before re-validating was an option. --- .../test/tests/ehr/AbstractEHRTest.java | 49 ++++++++++++++----- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/ehr/test/src/org/labkey/test/tests/ehr/AbstractEHRTest.java b/ehr/test/src/org/labkey/test/tests/ehr/AbstractEHRTest.java index 1b3e47cb8..de1d05bc3 100644 --- a/ehr/test/src/org/labkey/test/tests/ehr/AbstractEHRTest.java +++ b/ehr/test/src/org/labkey/test/tests/ehr/AbstractEHRTest.java @@ -47,6 +47,8 @@ import org.labkey.test.util.ehr.EHRTestHelper; import org.labkey.test.util.ext4cmp.Ext4CmpRef; import org.labkey.test.util.ext4cmp.Ext4FieldRef; +import org.openqa.selenium.NoSuchElementException; +import org.openqa.selenium.TimeoutException; import org.openqa.selenium.WebElement; import org.openqa.selenium.support.ui.ExpectedConditions; @@ -80,6 +82,9 @@ abstract public class AbstractEHRTest extends BaseWebDriverTest implements Advan // DataEntryErrorPanel's heading, on screen whenever the form is reporting anything at all private static final String FORM_ERROR_SUMMARY = "The form has the following errors and warnings:"; + // A submit waits longer than a field edit: the whole form has to go quiet, not one message + private static final int FORM_ERROR_SUMMARY_TIMEOUT = 30000; + public static final String PROJECT_ID = "640991"; // project with one participant public static final String PROJECT_ID_2 = "123456"; public static final String DUMMY_PROTOCOL = "dummyprotocol"; // need a protocol to create table entry @@ -1071,7 +1076,7 @@ public enum EHRQCState /** Waits for the form to stop reporting anything, so a submit does not race a stale error summary. */ protected void waitForFormValidationToClear() { - waitForValidationToClear(FORM_ERROR_SUMMARY); + waitForValidationToClear(FORM_ERROR_SUMMARY, FORM_ERROR_SUMMARY_TIMEOUT); } /** @@ -1081,12 +1086,20 @@ protected void waitForFormValidationToClear() */ protected void waitForValidationToClear(String message) { - if (waitForValidationToSettleWithout(message)) + waitForValidationToClear(message, WAIT_FOR_JAVASCRIPT); + } + + /** @see #waitForValidationToClear(String) */ + protected void waitForValidationToClear(String message, int timeout) + { + if (waitForValidationToSettleWithout(message, timeout)) return; log("Form kept reporting '" + message + "', re-validating"); - revalidateForm(); - if (!waitForValidationToSettleWithout(message)) + if (!revalidateForm()) + Assert.fail("Form kept reporting, and offers no Re-Validate to clear it: " + message); + + if (!waitForValidationToSettleWithout(message, timeout)) Assert.fail("Form kept reporting after re-validating: " + message); } @@ -1097,7 +1110,7 @@ protected void waitForValidationToClear(String message) * that raised it was accepted. Neither is worth acting on, so require no validation in flight and the message * absent, then re-check after the repaint window to confirm the absence survives it. */ - protected boolean waitForValidationToSettleWithout(String message) + protected boolean waitForValidationToSettleWithout(String message, int timeout) { return waitFor(() -> { if (getValidationRequestsInFlight() > 0 || isTextPresent(message)) @@ -1105,7 +1118,7 @@ protected boolean waitForValidationToSettleWithout(String message) sleep(ERROR_PANEL_REPAINT_BUFFER); return getValidationRequestsInFlight() == 0 && !isTextPresent(message); - }, WAIT_FOR_JAVASCRIPT); + }, timeout); } // Server validations the form is still waiting on. StoreCollection counts these itself; the form has no @@ -1120,12 +1133,26 @@ protected int getValidationRequestsInFlight() return inFlight == null ? 0 : ((Number) inFlight).intValue(); } - // More Actions -> Re-Validate: re-runs server-side validation on every record in the form - protected void revalidateForm() + /** + * More Actions -> Re-Validate: re-runs server-side validation on every record in the form. Not every form offers + * it, so report that rather than failing on the missing menu item and burying the message that would not clear. + * + * @return whether validation was re-run + */ + protected boolean revalidateForm() { - WebElement moreActions = _helper.getDataEntryButton("More Actions").findElement(getDriver()); - scrollIntoView(moreActions); - _ext4Helper.clickExt4MenuButton(false, moreActions, false, "Re-Validate"); + try + { + WebElement moreActions = _helper.getDataEntryButton("More Actions").findElement(getDriver()); + scrollIntoView(moreActions); + _ext4Helper.clickExt4MenuButton(false, moreActions, false, "Re-Validate"); + return true; + } + catch (NoSuchElementException | TimeoutException e) + { + log("Form offers no Re-Validate"); + return false; + } } protected void setupNotificationService() From 1341b8f52a41666638d991dc867f081b14b65d20 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Wed, 16 Sep 2026 12:03:07 -0600 Subject: [PATCH 5/5] Harden the shared validation wait against form states it cannot read The in-flight check reported zero whenever the form's scripting was not on the page, so the wait passed on a form that had not loaded, and it ignored the open grid editor that stops DataEntryErrorPanel repainting at all. Re-Validate is now looked up after waiting the toolbar out, so a button that has not rendered yet no longer reads as a form without the feature. --- .../test/tests/ehr/AbstractEHRTest.java | 74 ++++++++++++------- 1 file changed, 46 insertions(+), 28 deletions(-) diff --git a/ehr/test/src/org/labkey/test/tests/ehr/AbstractEHRTest.java b/ehr/test/src/org/labkey/test/tests/ehr/AbstractEHRTest.java index de1d05bc3..72413eaae 100644 --- a/ehr/test/src/org/labkey/test/tests/ehr/AbstractEHRTest.java +++ b/ehr/test/src/org/labkey/test/tests/ehr/AbstractEHRTest.java @@ -47,8 +47,6 @@ import org.labkey.test.util.ehr.EHRTestHelper; import org.labkey.test.util.ext4cmp.Ext4CmpRef; import org.labkey.test.util.ext4cmp.Ext4FieldRef; -import org.openqa.selenium.NoSuchElementException; -import org.openqa.selenium.TimeoutException; import org.openqa.selenium.WebElement; import org.openqa.selenium.support.ui.ExpectedConditions; @@ -63,6 +61,7 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.function.BooleanSupplier; import static org.junit.Assert.assertNull; import static org.junit.Assert.fail; @@ -76,8 +75,9 @@ abstract public class AbstractEHRTest extends BaseWebDriverTest implements Advan protected static final int POPULATE_TIMEOUT_MS = 300000; - // Longest buffer DataEntryErrorPanel puts between a validation event and repainting the error summary - protected static final int ERROR_PANEL_REPAINT_BUFFER = 1500; + // Longest buffer DataEntryErrorPanel puts between a validation event and repainting the error summary. It does + // not cover a repaint held off by an open grid editor, which isFormValidationQuiet() reports separately. + private static final int ERROR_PANEL_REPAINT_BUFFER = 1500; // DataEntryErrorPanel's heading, on screen whenever the form is reporting anything at all private static final String FORM_ERROR_SUMMARY = "The form has the following errors and warnings:"; @@ -1076,7 +1076,9 @@ public enum EHRQCState /** Waits for the form to stop reporting anything, so a submit does not race a stale error summary. */ protected void waitForFormValidationToClear() { - waitForValidationToClear(FORM_ERROR_SUMMARY, FORM_ERROR_SUMMARY_TIMEOUT); + // The summary heading is a container of its own, so a locator finds it without reading the whole page's text + Locator summary = Locator.tagContainingText("div", FORM_ERROR_SUMMARY); + waitForValidationToClear(FORM_ERROR_SUMMARY, () -> summary.existsIn(getDriver()), FORM_ERROR_SUMMARY_TIMEOUT); } /** @@ -1092,14 +1094,20 @@ protected void waitForValidationToClear(String message) /** @see #waitForValidationToClear(String) */ protected void waitForValidationToClear(String message, int timeout) { - if (waitForValidationToSettleWithout(message, timeout)) + // A message spans the error summary's label and text containers, so only the page's own text holds all of it + waitForValidationToClear(message, () -> isTextPresent(message), timeout); + } + + private void waitForValidationToClear(String message, BooleanSupplier reported, int timeout) + { + if (waitForValidationToSettleWithout(reported, timeout)) return; log("Form kept reporting '" + message + "', re-validating"); if (!revalidateForm()) Assert.fail("Form kept reporting, and offers no Re-Validate to clear it: " + message); - if (!waitForValidationToSettleWithout(message, timeout)) + if (!waitForValidationToSettleWithout(reported, timeout)) Assert.fail("Form kept reporting after re-validating: " + message); } @@ -1107,30 +1115,37 @@ protected void waitForValidationToClear(String message, int timeout) * Waits for the form to go quiet without reporting the given message. DataEntryErrorPanel repaints on a buffered * event rather than when the validation response lands, so the summary trails the form's actual state by up to a * second: a message can read as absent before validation has reported it, and read as present after the value - * that raised it was accepted. Neither is worth acting on, so require no validation in flight and the message - * absent, then re-check after the repaint window to confirm the absence survives it. + * that raised it was accepted. Neither is worth acting on, so require the form quiet and the message absent, then + * re-check after the repaint window to confirm the absence survives it. */ - protected boolean waitForValidationToSettleWithout(String message, int timeout) + private boolean waitForValidationToSettleWithout(BooleanSupplier reported, int timeout) { return waitFor(() -> { - if (getValidationRequestsInFlight() > 0 || isTextPresent(message)) + if (!isFormValidationQuiet() || reported.getAsBoolean()) return false; sleep(ERROR_PANEL_REPAINT_BUFFER); - return getValidationRequestsInFlight() == 0 && !isTextPresent(message); + return isFormValidationQuiet() && !reported.getAsBoolean(); }, timeout); } - // Server validations the form is still waiting on. StoreCollection counts these itself; the form has no - // rendered "validating" state to watch instead. Callers reach this mid-navigation, before Ext4 has loaded, so - // referencing it unguarded throws rather than reporting the nothing that is actually in flight. - protected int getValidationRequestsInFlight() + /** + * Whether the form has finished reporting: no server validation outstanding and no open grid editor. + * StoreCollection counts validation requests itself, and DataEntryPanel's rendered validating indicator cannot + * stand in for that count because it stays hidden through the form's initial load. An open editor counts because + * DataEntryErrorPanel skips the repaint entirely while one is up, leaving the summary at its pre-edit contents. + * + *

Not quiet when the form's scripting is not on the page. Callers reach this mid-navigation, and a wait that + * cannot yet tell has to keep polling rather than read the silence as a form that is done. + */ + private boolean isFormValidationQuiet() { - Object inFlight = executeScript("if (typeof Ext4 === 'undefined') return 0;" + + Object quiet = executeScript("if (typeof Ext4 === 'undefined') return null;" + "var panel = Ext4.ComponentQuery.query('ehr-dataentrypanel')[0];" + - "return panel && panel.storeCollection ? panel.storeCollection.validationRequestsInFlight : 0;"); + "if (!panel || !panel.storeCollection) return null;" + + "return panel.storeCollection.validationRequestsInFlight === 0 && !panel.isEditing();"); - return inFlight == null ? 0 : ((Number) inFlight).intValue(); + return Boolean.TRUE.equals(quiet); } /** @@ -1139,20 +1154,23 @@ protected int getValidationRequestsInFlight() * * @return whether validation was re-run */ - protected boolean revalidateForm() + private boolean revalidateForm() { - try - { - WebElement moreActions = _helper.getDataEntryButton("More Actions").findElement(getDriver()); - scrollIntoView(moreActions); - _ext4Helper.clickExt4MenuButton(false, moreActions, false, "Re-Validate"); - return true; - } - catch (NoSuchElementException | TimeoutException e) + // Only a form that has been churning for a full timeout gets here, so wait the toolbar out: a button that has + // simply not rendered yet must not read as a form without the feature + WebElement moreActions = _helper.getDataEntryButton("More Actions").waitForElement(getDriver(), WAIT_FOR_JAVASCRIPT); + scrollIntoView(moreActions); + _ext4Helper.openMenu(moreActions); + + WebElement revalidate = Ext4Helper.Locators.menuItem("Re-Validate").notHidden().findElementOrNull(getDriver()); + if (revalidate == null) { log("Form offers no Re-Validate"); return false; } + + revalidate.click(); + return true; } protected void setupNotificationService()