diff --git a/src/org/labkey/test/components/ui/AppPageHeader.java b/src/org/labkey/test/components/ui/AppPageHeader.java
new file mode 100644
index 0000000000..83a059fac9
--- /dev/null
+++ b/src/org/labkey/test/components/ui/AppPageHeader.java
@@ -0,0 +1,155 @@
+/*
+ * Copyright (c) 2018-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.components.ui;
+
+import org.labkey.test.Locator;
+import org.labkey.test.components.Component;
+import org.labkey.test.components.WebDriverComponent;
+import org.openqa.selenium.WebDriver;
+import org.openqa.selenium.WebElement;
+
+import static org.labkey.test.util.selenium.WebElementUtils.tryMapElement;
+
+/**
+ * Wraps component
+ */
+public class AppPageHeader extends WebDriverComponent
+{
+ private final WebElement _el;
+ private final WebDriver _driver;
+
+ protected AppPageHeader(WebElement element, WebDriver driver)
+ {
+ _el = element;
+ _driver = driver;
+ }
+
+ @Override
+ public WebElement getComponentElement()
+ {
+ return _el;
+ }
+
+ @Override
+ public WebDriver getDriver()
+ {
+ return _driver;
+ }
+
+ /**
+ * Gets the text of the page header. If there is no header returns an empty string.
+ *
+ * @return Text from the page title, empty string if element is not there.
+ */
+ public String getTitle()
+ {
+ return tryMapElement(elementCache().title, WebElement::getText);
+ }
+
+ /**
+ * Get the text of the subtitle of the page. If there is no subtitle, return an empty string.
+ *
+ * @return Text from the page subtitle, empty string if element is not there.
+ */
+ public String getSubtitle()
+ {
+ return tryMapElement(elementCache().subtitle, WebElement::getText);
+ }
+
+ /**
+ * Get the text of the description of the page. If there is no description, returns an empty string.
+ *
+ * @return Text from the page description, empty string if element is not there.
+ */
+ public String getDescription()
+ {
+ return tryMapElement(elementCache().description, WebElement::getText);
+ }
+
+ /**
+ * @throws UnsupportedOperationException Label color is not supported by AppPageHeader.
+ */
+ public String getLabelColor()
+ {
+ throw new UnsupportedOperationException("Label color is not supported by AppPageHeader.");
+ }
+
+ /**
+ * Get the source file of the page icon. If there is no icon returns an empty string.
+ *
+ * @return The 'src' attribute header icon, empty string if element is not there.
+ */
+ public String getIconSource()
+ {
+ return tryMapElement(elementCache().icon, el -> el.getDomAttribute("src"));
+ }
+
+ @Override
+ protected ElementCache newElementCache()
+ {
+ return new ElementCache();
+ }
+
+ protected class ElementCache extends Component.ElementCache
+ {
+ public final WebElement icon = getIconLocator().findWhenNeeded(this);
+ public final WebElement title = getTitleLocator().findWhenNeeded(this);
+ public final WebElement subtitle = getSubtitleLocator().findWhenNeeded(this);
+ public final WebElement description = getDescriptionLocator().findWhenNeeded(this);
+
+ protected Locator.XPathLocator getIconLocator()
+ {
+ return Locator.byClass("app-page-header__icon");
+ }
+
+ protected Locator.XPathLocator getTitleLocator()
+ {
+ return Locator.byClass("app-page-header__title");
+ }
+
+ protected Locator.XPathLocator getSubtitleLocator()
+ {
+ return Locator.byClass("app-page-header__subtitle");
+ }
+
+ protected Locator.XPathLocator getDescriptionLocator()
+ {
+ return Locator.byClass("app-page-header__description");
+ }
+ }
+
+ public static class AppPageHeaderFinder extends WebDriverComponentFinder
+ {
+ private final Locator.XPathLocator _baseLocator = Locator.byClass("app-page-header");
+
+ public AppPageHeaderFinder(WebDriver driver)
+ {
+ super(driver);
+ }
+
+ @Override
+ protected AppPageHeader construct(WebElement el, WebDriver driver)
+ {
+ return new AppPageHeader(el, driver);
+ }
+
+ @Override
+ protected Locator locator()
+ {
+ return _baseLocator;
+ }
+ }
+}
diff --git a/src/org/labkey/test/components/ui/PageDetailHeader.java b/src/org/labkey/test/components/ui/PageDetailHeader.java
new file mode 100644
index 0000000000..8c73c4ea62
--- /dev/null
+++ b/src/org/labkey/test/components/ui/PageDetailHeader.java
@@ -0,0 +1,107 @@
+/*
+ * Copyright (c) 2018-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.components.ui;
+
+import org.labkey.test.Locator;
+import org.openqa.selenium.WebDriver;
+import org.openqa.selenium.WebElement;
+
+import static org.labkey.test.util.selenium.WebElementUtils.tryMapElement;
+
+/**
+ * Wraps component
+ */
+public class PageDetailHeader extends AppPageHeader
+{
+ protected PageDetailHeader(WebElement element, WebDriver driver)
+ {
+ super(element, driver);
+ }
+
+ /**
+ * Get the rgb style value for the label color in the header
+ *
+ * @return A string such as "rgb(104, 204, 202)" as used in the "color-icon__circle-small" "i" element in the detail header or the empty string if element is not there.
+ */
+ @Override
+ public String getLabelColor()
+ {
+ return tryMapElement(elementCache().colorIcon, el -> el.getCssValue("background-color"));
+ }
+
+ @Override
+ protected ElementCache elementCache()
+ {
+ return (ElementCache) super.elementCache();
+ }
+
+ @Override
+ protected ElementCache newElementCache()
+ {
+ return new ElementCache();
+ }
+
+ protected class ElementCache extends AppPageHeader.ElementCache
+ {
+ @Override
+ protected Locator.XPathLocator getTitleLocator()
+ {
+ return Locator.byClass("detail__header--name");
+ }
+
+ @Override
+ protected Locator.XPathLocator getDescriptionLocator()
+ {
+ return Locator.byClass("detail__header--desc");
+ }
+
+ @Override
+ protected Locator.XPathLocator getSubtitleLocator()
+ {
+ return Locator.byClass("detail-subtitle");
+ }
+
+ @Override
+ protected Locator.XPathLocator getIconLocator()
+ {
+ return Locator.byClass("detail__header-icon");
+ }
+
+ final WebElement colorIcon = Locator.byClass("color-icon__circle-small").findWhenNeeded(subtitle);
+ }
+
+ public static class PageDetailHeaderFinder extends WebDriverComponentFinder
+ {
+ private final Locator.XPathLocator _baseLocator = Locator.byClass("page-header");
+
+ public PageDetailHeaderFinder(WebDriver driver)
+ {
+ super(driver);
+ }
+
+ @Override
+ protected PageDetailHeader construct(WebElement el, WebDriver driver)
+ {
+ return new PageDetailHeader(el, driver);
+ }
+
+ @Override
+ protected Locator locator()
+ {
+ return _baseLocator;
+ }
+ }
+}
diff --git a/src/org/labkey/test/util/selenium/WebElementUtils.java b/src/org/labkey/test/util/selenium/WebElementUtils.java
index a62edd0fbd..5aee1264cb 100644
--- a/src/org/labkey/test/util/selenium/WebElementUtils.java
+++ b/src/org/labkey/test/util/selenium/WebElementUtils.java
@@ -27,6 +27,7 @@
import java.util.List;
import java.util.Objects;
import java.util.Optional;
+import java.util.function.Function;
import static org.labkey.test.Locator.NBSP;
@@ -137,4 +138,34 @@ public static boolean checkVisibility(WebElement element)
return false;
}
}
+
+ /**
+ * Convenience method to extract some attribute from a WebElement. Stale or missing elements will return a default.
+ * @param element WebElement to inspect; may be stale or backed by a missing DOM node
+ * @param mapper function applied to {@code element} to extract the desired value
+ * @param defaultValue value returned when {@code element} is stale or missing
+ * @return the mapped value, or {@code defaultValue} if the element is unavailable
+ * @param type of the extracted value
+ */
+ public static T tryMapElement(WebElement element, Function mapper, T defaultValue)
+ {
+ try
+ {
+ return mapper.apply(element);
+ }
+ catch (NoSuchElementException | StaleElementReferenceException _) { }
+
+ return defaultValue;
+ }
+
+ /**
+ * Convenience overload of {@link #tryMapElement(WebElement, Function, Object)} that defaults to an empty string.
+ * @param element WebElement to inspect; may be stale or backed by a missing DOM node
+ * @param mapper function applied to {@code element} to extract a String value
+ * @return the mapped value, or {@code ""} if the element is unavailable
+ */
+ public static String tryMapElement(WebElement element, Function mapper)
+ {
+ return tryMapElement(element, mapper, "");
+ }
}