diff --git a/build.gradle b/build.gradle index aa002ab263..fbb5ca94d0 100644 --- a/build.gradle +++ b/build.gradle @@ -36,6 +36,9 @@ project.dependencies { implementation("org.seleniumhq.selenium:selenium-remote-driver:${seleniumVersion}") implementation("org.seleniumhq.selenium:selenium-chrome-driver:${seleniumVersion}") implementation "org.seleniumhq.selenium:selenium-ie-driver:${seleniumVersion}" + + implementation("com.deque.html.axe-core:selenium:${axeCoreSeleniumVersion}") + implementation("org.eclipse.jetty:jetty-util:${jettyVersion}") implementation("com.google.guava:guava:${guavaVersion}") diff --git a/gradle.properties b/gradle.properties index c1d3217e49..8779c6e8e2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,6 +6,8 @@ assertjVersion=3.27.7 awaitilityVersion=4.3.0 +axeCoreSeleniumVersion=4.11.1 + lookfirstSardineVersion=5.13 jettyVersion=12.1.5 diff --git a/src/org/labkey/test/TestProperties.java b/src/org/labkey/test/TestProperties.java index 82ed8da3d2..4ab4c3cac3 100644 --- a/src/org/labkey/test/TestProperties.java +++ b/src/org/labkey/test/TestProperties.java @@ -352,6 +352,11 @@ public static boolean ignoreDatabaseNotSupportedException() return "true".equals(System.getProperty("webtest.ignoreDatabaseNotSupportedException")); } + public static boolean isAccessibilityCheckEnabled() + { + return "true".equals(System.getProperty("webtest.enableAccessibilityCheck", "true")); // TODO: default to "false" if lots of TeamCity noise + } + /** * Parses system property 'webtest.server.startup.timeout' to determine maximum allowed server startup time. * If property is not defined or is not an integer, it defaults to 120 seconds. diff --git a/src/org/labkey/test/WebDriverWrapper.java b/src/org/labkey/test/WebDriverWrapper.java index af3e2b6a67..280b8a022a 100644 --- a/src/org/labkey/test/WebDriverWrapper.java +++ b/src/org/labkey/test/WebDriverWrapper.java @@ -47,6 +47,7 @@ import org.labkey.test.pages.study.ManageStudyPage; import org.labkey.test.pages.user.ShowUsersPage; import org.labkey.test.selenium.EphemeralWebElement; +import org.labkey.test.util.AccessibilityUtils; import org.labkey.test.util.CodeMirrorHelper; import org.labkey.test.util.Crawler; import org.labkey.test.util.EscapeUtil; @@ -2134,6 +2135,8 @@ public long doAndMaybeWaitForPageToLoad(int msWait, Supplier action) String warning = "Action doesn't define a page title"; addActionWarning(warning, getDriver().getCurrentUrl()); } + + AccessibilityUtils.scanPage(getDriver()); } return loadTimer.elapsed().toMillis(); diff --git a/src/org/labkey/test/components/Component.java b/src/org/labkey/test/components/Component.java index 11a1b5057d..589e66c7bd 100644 --- a/src/org/labkey/test/components/Component.java +++ b/src/org/labkey/test/components/Component.java @@ -15,11 +15,15 @@ */ package org.labkey.test.components; +import com.deque.html.axecore.results.Results; +import com.deque.html.axecore.selenium.AxeBuilder; import org.apache.commons.lang3.NotImplementedException; import org.jetbrains.annotations.NotNull; import org.labkey.test.Locator; import org.labkey.test.selenium.RefindingWebElement; +import org.labkey.test.util.AccessibilityUtils; import org.labkey.test.util.TestLogger; +import org.labkey.test.util.selenium.WebDriverUtils; import org.openqa.selenium.By; import org.openqa.selenium.NoSuchElementException; import org.openqa.selenium.SearchContext; @@ -79,12 +83,20 @@ protected EC elementCache() // It succeeded, so it should be safe to get a fresh `newElementCache` _elementCache = Objects.requireNonNull(newElementCache()); } + + if (shouldScanAfterReady()) + AccessibilityUtils.scanComponent(this); } return _elementCache; } protected void waitForReady() { } + protected boolean shouldScanAfterReady() + { + return false; + } + protected EC newElementCache() { throw new NotImplementedException("Please override newElementCache() in your component class"); diff --git a/src/org/labkey/test/components/bootstrap/ModalDialog.java b/src/org/labkey/test/components/bootstrap/ModalDialog.java index 2afdb12439..ad7c766302 100644 --- a/src/org/labkey/test/components/bootstrap/ModalDialog.java +++ b/src/org/labkey/test/components/bootstrap/ModalDialog.java @@ -64,6 +64,12 @@ protected void waitForReady() !elementCache().body.getText().isEmpty(), "Modal dialog not ready.", 2_500); } + @Override + protected boolean shouldScanAfterReady() + { + return true; + } + @Override public WebElement getComponentElement() { diff --git a/src/org/labkey/test/components/ui/grids/GridFilterModal.java b/src/org/labkey/test/components/ui/grids/GridFilterModal.java index b597356abf..84da92d23b 100644 --- a/src/org/labkey/test/components/ui/grids/GridFilterModal.java +++ b/src/org/labkey/test/components/ui/grids/GridFilterModal.java @@ -43,6 +43,7 @@ protected void waitForReady() */ public GridFilterModal selectField(CharSequence fieldIdentifier) { + getWrapper().shortWait().until(ExpectedConditions.invisibilityOfElementLocated(Locator.byClass("field-modal__empty-msg"))); WebElement fieldItem = elementCache().findFieldOption(fieldIdentifier); getWrapper().scrollIntoView(fieldItem); String fieldLabel = WebElementUtils.getTextContent(fieldItem); diff --git a/src/org/labkey/test/pages/LabKeyPage.java b/src/org/labkey/test/pages/LabKeyPage.java index bf253fabf0..a9a4dcf6d9 100644 --- a/src/org/labkey/test/pages/LabKeyPage.java +++ b/src/org/labkey/test/pages/LabKeyPage.java @@ -15,9 +15,13 @@ */ package org.labkey.test.pages; +import com.deque.html.axecore.results.Results; +import com.deque.html.axecore.selenium.AxeBuilder; import org.labkey.test.BaseWebDriverTest; import org.labkey.test.Locators; import org.labkey.test.WebDriverWrapper; +import org.labkey.test.util.AccessibilityUtils; +import org.labkey.test.util.TestLogger; import org.openqa.selenium.By; import org.openqa.selenium.SearchContext; import org.openqa.selenium.WebDriver; @@ -45,6 +49,8 @@ public LabKeyPage(WrapsDriver test) _test = (BaseWebDriverTest)test; _wrapsDriver = test; waitForPage(); + + AccessibilityUtils.scanPage(this); } public LabKeyPage(WebDriver driver) diff --git a/src/org/labkey/test/util/AccessibilityUtils.java b/src/org/labkey/test/util/AccessibilityUtils.java new file mode 100644 index 0000000000..e406ff641d --- /dev/null +++ b/src/org/labkey/test/util/AccessibilityUtils.java @@ -0,0 +1,77 @@ +package org.labkey.test.util; + +import com.deque.html.axecore.results.Results; +import com.deque.html.axecore.selenium.AxeBuilder; +import org.jspecify.annotations.NonNull; +import org.labkey.test.TestProperties; +import org.labkey.test.components.Component; +import org.labkey.test.pages.LabKeyPage; +import org.labkey.test.util.selenium.WebDriverUtils; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +public class AccessibilityUtils +{ + public static void scanPage(WebDriver driver) + { + Results results = getAnalyzer().analyze(driver); + recordViolations(results, "page"); + } + + public static void scanPage(LabKeyPage page) + { + Results results = getAnalyzer().analyze(WebDriverUtils.extractWrappedDriver(page.getWrappedDriver())); + recordViolations(results, page.getClass().getSimpleName()); + } + + public static void scanComponent(Component component) + { + Results results = getAnalyzer().analyze(WebDriverUtils.extractWrappedDriver(component.getComponentElement()), component.getComponentElement()); + recordViolations(results, component.getClass().getSimpleName()); + } + + private static @NonNull AxeBuilder getAnalyzer() + { + if (TestProperties.isAccessibilityCheckEnabled()) + return new AxeBuilder(); + else + return NoOpAxeBuilder.get(); + } + + private static void recordViolations(Results results, String component) + { + if (results.isErrored()) + { + TestLogger.error("Accessibility violations found on " + component); + results.getViolations().forEach(violation -> TestLogger.error(violation.getDescription())); + } + } +} + +class NoOpAxeBuilder extends AxeBuilder +{ + private static final CachingSupplier INSTANCE = new CachingSupplier<>(NoOpAxeBuilder::new); + + static NoOpAxeBuilder get() + { + return INSTANCE.get(); + } + + @Override + public Results analyze(WebDriver webDriver, WebElement... context) + { + return new Results(); + } + + @Override + public Results analyze(WebDriver webDriver) + { + return new Results(); + } + + @Override + public Results analyze(WebDriver webDriver, boolean injectAxe) + { + return new Results(); + } +} \ No newline at end of file