diff --git a/build.gradle b/build.gradle index af7a8fe084..fc5eb041e0 100644 --- a/build.gradle +++ b/build.gradle @@ -26,6 +26,7 @@ project.dependencies { implementation("commons-io:commons-io:${commonsIoVersion}") implementation("com.fasterxml.jackson.core:jackson-annotations:${jacksonAnnotationsVersion}") implementation("org.bouncycastle:bcprov-jdk18on:${bouncycastleVersion}") + implementation("org.apache.commons:commons-csv:${apacheCommonsCsvVersion}") //api "org.seleniumhq.selenium:selenium-server:${seleniumVersion}" implementation("org.seleniumhq.selenium:selenium-firefox-driver:${seleniumVersion}") diff --git a/gradle.properties b/gradle.properties index 41a6ecc5e7..834394698c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,10 +1,17 @@ +apacheCommonsCsvVersion=1.14.0 + aspectjVersion=1.9.23 + assertjVersion=3.27.3 + awaitilityVersion=4.3.0 lookfirstSardineVersion=5.13 + jettyVersion=12.0.18 + seleniumVersion=4.27.0 + mockserverNettyVersion=5.15.0 labkeySchemasTestVersion=25.3-SNAPSHOT diff --git a/src/org/labkey/test/Locator.java b/src/org/labkey/test/Locator.java index 34c1d94387..2bf353fa6c 100644 --- a/src/org/labkey/test/Locator.java +++ b/src/org/labkey/test/Locator.java @@ -25,6 +25,7 @@ import org.labkey.test.selenium.ReclickingWebElement; import org.labkey.test.selenium.RefindingWebElement; import org.labkey.test.util.TestLogger; +import org.labkey.test.util.TextUtils; import org.labkey.test.util.selenium.WebDriverUtils; import org.openqa.selenium.By; import org.openqa.selenium.InvalidSelectorException; @@ -185,23 +186,30 @@ protected WebDriver getWebDriver(SearchContext context) */ public static WebElement waitForAnyElement(FluentWait wait, final Locator... locators) { - return wait.until(new Function() + try { - @Override - public WebElement apply(SearchContext context) + return wait.until(new Function() { - return findAnyElementOrNull(context, locators); - } + @Override + public WebElement apply(SearchContext context) + { + return findAnyElementOrNull(context, locators); + } - @Override - public String toString() - { - List locDescriptions = new ArrayList<>(); - Arrays.stream(locators).forEach(loc -> locDescriptions.add(loc.getLoggableDescription())); - SearchContext searchContext = extractInputFromFluentWait(wait); - return String.join("\n--OR--\n", locDescriptions) + (searchContext instanceof WebDriver ? "" : "\nIN: " + searchContext.toString()); - } - }); + @Override + public String toString() + { + List locDescriptions = new ArrayList<>(); + Arrays.stream(locators).forEach(loc -> locDescriptions.add(loc.getLoggableDescription())); + SearchContext searchContext = extractInputFromFluentWait(wait); + return String.join("\n--OR--\n", locDescriptions) + (searchContext instanceof WebDriver ? "" : "\nIN: " + searchContext.toString()); + } + }); + } + catch (TimeoutException e) + { + throw new NoSuchElementException(e.getMessage(), e); + } } /** @@ -210,28 +218,34 @@ public String toString() */ public static List waitForElements(FluentWait wait, final Locator... locators) { - return wait.until(new Function>() + try { - @Override - public List apply(SearchContext context) - { - List els = findElements(context, locators); - if (els.size() > 0) - return els; - else - return null; - } - - @Override - public String toString() + return wait.until(new Function>() { - List locDescriptions = new ArrayList<>(); - Arrays.stream(locators).forEach(loc -> locDescriptions.add(loc.getLoggableDescription())); - SearchContext searchContext = extractInputFromFluentWait(wait); - return String.join("\n--OR--\n", locDescriptions) + (searchContext instanceof WebDriver ? "" : "\nIN: " + searchContext.toString()); - } - }); + @Override + public List apply(SearchContext context) + { + List els = findElements(context, locators); + if (!els.isEmpty()) + return els; + else + return null; + } + @Override + public String toString() + { + List locDescriptions = new ArrayList<>(); + Arrays.stream(locators).forEach(loc -> locDescriptions.add(loc.getLoggableDescription())); + SearchContext searchContext = extractInputFromFluentWait(wait); + return String.join("\n--OR--\n", locDescriptions) + (searchContext instanceof WebDriver ? "" : "\nIN: " + searchContext.toString()); + } + }); + } + catch (TimeoutException e) + { + throw new NoSuchElementException(e.getMessage(), e); + } } public static List findElements(SearchContext context, final Locator... locators) @@ -984,7 +998,7 @@ public static String xq(String value) */ private static String ns(String value) { - return value.replaceAll("\\s+", " ").trim(); + return TextUtils.normalizeSpace(value); } public static String cq(String value) diff --git a/src/org/labkey/test/components/domain/DomainFieldRow.java b/src/org/labkey/test/components/domain/DomainFieldRow.java index 8ec1709fb1..1f24c94165 100644 --- a/src/org/labkey/test/components/domain/DomainFieldRow.java +++ b/src/org/labkey/test/components/domain/DomainFieldRow.java @@ -19,6 +19,7 @@ import org.labkey.test.pages.core.admin.BaseSettingsPage.TIME_FORMAT; import org.labkey.test.params.FieldDefinition; import org.labkey.test.util.LabKeyExpectedConditions; +import org.labkey.test.util.selenium.WebElementUtils; import org.openqa.selenium.ElementNotInteractableException; import org.openqa.selenium.NoSuchElementException; import org.openqa.selenium.SearchContext; @@ -1795,8 +1796,8 @@ public WebElement hitSelectionCriteriaButton() public List hitSelectionCriteria() { - return getWrapper().getTexts(Locator.tagWithClass("li", "hit-criteria-renderer__field-value") - .findElements(this)); + return Locator.tagWithClass("li", "hit-criteria-renderer__field-value") + .findElements(this).stream().map(WebElementUtils::getTextContent).toList(); } public RadioButton aliquotOption(ExpSchema.DerivationDataScopeType option) diff --git a/src/org/labkey/test/components/domain/HitSelectionDialog.java b/src/org/labkey/test/components/domain/HitSelectionDialog.java index 68d61e1c99..50b626c55f 100644 --- a/src/org/labkey/test/components/domain/HitSelectionDialog.java +++ b/src/org/labkey/test/components/domain/HitSelectionDialog.java @@ -1,19 +1,14 @@ package org.labkey.test.components.domain; import org.labkey.test.Locator; -import org.labkey.test.components.Component; -import org.labkey.test.components.WebDriverComponent; import org.labkey.test.components.bootstrap.ModalDialog; -import org.labkey.test.components.html.Input; import org.labkey.test.components.ui.search.FilterExpressionPanel; -import org.labkey.test.pages.LabKeyPage; +import org.labkey.test.util.selenium.WebElementUtils; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebElement; import java.util.List; -import static org.labkey.test.components.html.Input.Input; - public class HitSelectionDialog extends ModalDialog { @@ -23,9 +18,9 @@ public HitSelectionDialog(WebDriver driver) super(new ModalDialogFinder(driver)); } - public List getAvailableFields() + public List getAvailableFieldLabels() { - return getWrapper().getTexts(elementCache().findFieldOptions()); + return elementCache().findFieldOptions().stream().map(WebElementUtils::getTextContent).toList(); } public FilterExpressionPanel selectField(String fieldName) diff --git a/src/org/labkey/test/components/react/BaseReactSelect.java b/src/org/labkey/test/components/react/BaseReactSelect.java index 01ee6fc83d..3af1d1c115 100644 --- a/src/org/labkey/test/components/react/BaseReactSelect.java +++ b/src/org/labkey/test/components/react/BaseReactSelect.java @@ -23,6 +23,7 @@ import java.util.Collections; import java.util.List; +import java.util.function.Function; import java.util.stream.Collectors; import static org.labkey.test.WebDriverWrapper.WAIT_FOR_JAVASCRIPT; @@ -365,7 +366,7 @@ public List getOptionElements() * * @return List of strings for the values in the list. */ - public List getOptions() + public List getOptions(Function optionMapper) { boolean alreadyOpened = isExpanded(); @@ -374,16 +375,20 @@ public List getOptions() if (!alreadyOpened) open(); - List selectedItems = Locators.listItems.findElements(getComponentElement()); - List rawItems = getWrapper().getTexts(selectedItems); + List optionElements = Locators.listItems.findElements(getComponentElement()); + List rawItems = optionElements.stream().map(optionMapper).toList(); // If it wasn't open before close it, otherwise leave it in the open state. if (!alreadyOpened) close(); - return rawItems.stream().map(String::trim).collect(Collectors.toList()); + return rawItems; } + public List getOptions() + { + return getOptions(el -> el.getText().trim()); + } public String getName() { diff --git a/src/org/labkey/test/components/react/FilteringReactSelect.java b/src/org/labkey/test/components/react/FilteringReactSelect.java index de8713eceb..b9c6d1f0eb 100644 --- a/src/org/labkey/test/components/react/FilteringReactSelect.java +++ b/src/org/labkey/test/components/react/FilteringReactSelect.java @@ -163,7 +163,7 @@ public FilteringReactSelect filterSelect(String value, Locator elementToWaitFor) return this; } - private List setFilter(String value) + public List setFilter(String value) { open(); elementCache().input.sendKeys(value); diff --git a/src/org/labkey/test/components/ui/entities/EntityBulkUpdateDialog.java b/src/org/labkey/test/components/ui/entities/EntityBulkUpdateDialog.java index aa057d495d..f71f51469f 100644 --- a/src/org/labkey/test/components/ui/entities/EntityBulkUpdateDialog.java +++ b/src/org/labkey/test/components/ui/entities/EntityBulkUpdateDialog.java @@ -44,6 +44,7 @@ public EntityBulkUpdateDialog(WebDriver driver, UpdatingComponent updatingCompon { super(new ModalDialogFinder(driver).withTitle("Update ")); _updatingComponent = updatingComponent; + getWrapper().mouseOver(elementCache().title); // avoid accidentally triggering tooltips } /** @@ -212,9 +213,8 @@ public List getFieldNames() { List labels = Locator.tagWithClass("label", "control-label").withAttribute("for") .waitForElements(elementCache(), 2_000); - List columns = new ArrayList<>(); - labels.forEach(a -> columns.add(a.getDomAttribute("for"))); - return columns; + + return labels.stream().map(a -> EscapeUtil.fieldKeyDecodePart(a.getDomAttribute("for"))).toList(); } public EntityBulkUpdateDialog waitForFieldsToBe(List expectedFieldNames, int waitMilliseconds) diff --git a/src/org/labkey/test/components/ui/grids/DetailTable.java b/src/org/labkey/test/components/ui/grids/DetailTable.java index 9a20d5345f..41ceaa78e4 100644 --- a/src/org/labkey/test/components/ui/grids/DetailTable.java +++ b/src/org/labkey/test/components/ui/grids/DetailTable.java @@ -15,6 +15,7 @@ import java.util.Map; import static org.labkey.test.WebDriverWrapper.WAIT_FOR_JAVASCRIPT; +import static org.labkey.test.util.selenium.WebElementUtils.getTextContent; /** * This is a 'special' table that has only two columns, and no header. An example of this table can be seen in the @@ -155,7 +156,7 @@ public Map getTableDataByLabel() { List tds = tableRow.findElements(By.tagName("td")); - tableData.put(tds.get(0).getText(), tds.get(1).getText()); + tableData.put(getTextContent(tds.get(0)), tds.get(1).getText()); } return tableData; diff --git a/src/org/labkey/test/components/ui/grids/DetailTableEdit.java b/src/org/labkey/test/components/ui/grids/DetailTableEdit.java index abd8357051..dc7eb61dec 100644 --- a/src/org/labkey/test/components/ui/grids/DetailTableEdit.java +++ b/src/org/labkey/test/components/ui/grids/DetailTableEdit.java @@ -1,7 +1,6 @@ package org.labkey.test.components.ui.grids; import org.junit.Assert; -import org.labkey.api.query.QueryKey; import org.labkey.remoteapi.CommandException; import org.labkey.test.BootstrapLocators; import org.labkey.test.Locator; @@ -16,6 +15,7 @@ import org.labkey.test.components.ui.files.FileUploadField; import org.labkey.test.params.FieldDefinition; import org.labkey.test.util.AuditLogHelper; +import org.labkey.test.util.EscapeUtil; import org.openqa.selenium.By; import org.openqa.selenium.NoSuchElementException; import org.openqa.selenium.WebDriver; @@ -87,7 +87,7 @@ public DetailTableEdit adjustChangeCounter(int change) public boolean isFieldPresent(String fieldLabel) { - return elementCache().fieldValue(fieldLabel) != null; + return elementCache().valueCellWithLabel(fieldLabel) != null; } /** * Check to see if a field is editable. Could be state dependent, that is it returns false if the field is @@ -99,7 +99,7 @@ public boolean isFieldPresent(String fieldLabel) public boolean isFieldEditable(String fieldLabel) { // TODO Could put a check here to see if a field is loading then return false, or wait. - WebElement fieldValueElement = elementCache().fieldValue(fieldLabel); + WebElement fieldValueElement = elementCache().valueCellWithLabel(fieldLabel); return isEditableField(fieldValueElement); } @@ -117,7 +117,7 @@ private boolean isEditableField(WebElement element) **/ public String getReadOnlyField(String fieldLabel) { - WebElement fieldValueElement = elementCache().fieldValue(fieldLabel); + WebElement fieldValueElement = elementCache().valueCellWithLabel(fieldLabel); return fieldValueElement.findElement(By.xpath("./div/*")).getText(); } @@ -129,7 +129,7 @@ public String getReadOnlyField(String fieldLabel) **/ public String getTextField(String fieldLabel) { - WebElement fieldValueElement = elementCache().fieldValue(fieldLabel); + WebElement fieldValueElement = elementCache().valueCellWithLabel(fieldLabel); WebElement textElement = fieldValueElement.findElement(By.xpath("./div/div/*")); if(textElement.getTagName().equalsIgnoreCase("textarea")) return textElement.getText(); @@ -148,7 +148,7 @@ public DetailTableEdit setTextField(String fieldLabel, String value) { if(isFieldEditable(fieldLabel)) { - WebElement fieldValueElement = elementCache().fieldValue(fieldLabel); + WebElement fieldValueElement = elementCache().valueCellWithLabel(fieldLabel); WebElement editableElement = fieldValueElement.findElement(By.xpath("./div/div/*")); String elementType = editableElement.getTagName().toLowerCase().trim(); @@ -205,7 +205,7 @@ public DetailTableEdit setTextareaByFieldName(String fieldName, String value) public boolean getBooleanField(String fieldLabel) { // The text used in the field label and the value of the name attribute in the checkbox don't always have the same case. - WebElement editableElement = Locator.tag("input").findElement(elementCache().fieldValue(fieldLabel)); + WebElement editableElement = Locator.tag("input").findElement(elementCache().valueCellWithLabel(fieldLabel)); String elementType = editableElement.getDomAttribute("type").toLowerCase().trim(); Assert.assertEquals(String.format("Field '%s' is not a checkbox. Cannot be get true/false value.", fieldLabel), "checkbox", elementType); @@ -223,7 +223,7 @@ public boolean getBooleanField(String fieldLabel) public DetailTableEdit setBooleanField(String fieldLabel, boolean value) { - WebElement fieldValueElement = elementCache().fieldValue(fieldLabel); + WebElement fieldValueElement = elementCache().valueCellWithLabel(fieldLabel); Assert.assertTrue(String.format("Field '%s' is not editable and cannot be set.", fieldLabel), isEditableField(fieldValueElement)); getWrapper().scrollIntoView(fieldValueElement); @@ -387,7 +387,7 @@ public DetailTableEdit clearSelectValue(String fieldLabel, boolean waitForSelect /** * Set a DateTime, Date or Time field. - * @param fieldKey The encoded fieldKey of the field to set. + * @param fieldName The name of the field to set. * @param dateTime Will be used to determine what kind of field is being set and how to set it. If the parameter * is a LocalDateTime object then it is assumed that field is a DateTime field. If the parameter is * a LocalDate object then it is assumed to be a date-only field. And I think you can guess what @@ -395,9 +395,9 @@ public DetailTableEdit clearSelectValue(String fieldLabel, boolean waitForSelect * is typed into the field (no picker is used). * @return A reference to this DetailTableEdit object. */ - public DetailTableEdit setDateTimeField(String fieldKey, Object dateTime) + public DetailTableEdit setDateTimeField(String fieldName, Object dateTime) { - ReactDateTimePicker dateTimePicker = getDateTimePicker(fieldKey); + ReactDateTimePicker dateTimePicker = getDateTimePicker(fieldName); if(dateTime instanceof LocalDateTime localDateTime) { dateTimePicker.select(localDateTime); @@ -424,23 +424,22 @@ else if(dateTime instanceof String setValue) return this; } - public String getDateTimeField(String fieldLabel) + public String getDateTimeField(String fieldName) { - ReactDateTimePicker dateTimePicker = getDateTimePicker(fieldLabel); + ReactDateTimePicker dateTimePicker = getDateTimePicker(fieldName); return dateTimePicker.get(); } - public void clearDateTimeField(String fieldLabel) + public void clearDateTimeField(String fieldName) { - ReactDateTimePicker dateTimePicker = getDateTimePicker(fieldLabel); + ReactDateTimePicker dateTimePicker = getDateTimePicker(fieldName); dateTimePicker.clear(); _changeCounter++; } - private ReactDateTimePicker getDateTimePicker(String fieldLabel) + private ReactDateTimePicker getDateTimePicker(String fieldName) { - return new ReactDateTimePicker.ReactDateTimeInputFinder(getDriver()) - .withInputId(fieldLabel).find(this); + return new ReactDateTimePicker.ReactDateTimeInputFinder(getDriver()).find(elementCache().valueCellWithName(fieldName)); } // For use when the field is of an unknown type, as can occur in fuzz tests @@ -452,7 +451,7 @@ public void setDetails(FieldDefinition field, Object newValue) if (field.getType() == FieldDefinition.ColumnType.TextChoice) setSelectValue(field.getLabel(), (List) newValue); else if (field.getType() == FieldDefinition.ColumnType.Date || field.getType() == FieldDefinition.ColumnType.DateAndTime || field.getType() == FieldDefinition.ColumnType.Time) - setDateTimeField(QueryKey.encodePart(field.getName()), newValue); + setDateTimeField(field.getName(), newValue); else if (field.getType() == FieldDefinition.ColumnType.Boolean) setBooleanField(field.getLabel(), (Boolean) newValue); else @@ -586,14 +585,19 @@ public ElementCache() public WebElement editPanel = Locator.tagWithClass("div", "detail__editing") .findWhenNeeded(this); - public WebElement fieldValue(String label) + public WebElement valueCellWithLabel(String label) { return Locator.tagWithAttribute("td", "data-caption", label).findElementOrNull(editPanel); } + public WebElement valueCellWithName(String fieldName) + { + return Locator.tagWithAttribute("td", "data-fieldkey", EscapeUtil.fieldKeyEncodePart(fieldName).toLowerCase()).findElement(editPanel); + } + public FileUploadField fileField(String label) { - return new FileUploadField(fieldValue(label), getDriver()); + return new FileUploadField(valueCellWithLabel(label), getDriver()); } public Locator validationMsg = Locator.tagWithClass("span", "validation-message"); diff --git a/src/org/labkey/test/components/ui/grids/EditableGrid.java b/src/org/labkey/test/components/ui/grids/EditableGrid.java index 9a7294a4a2..ca33d6a355 100644 --- a/src/org/labkey/test/components/ui/grids/EditableGrid.java +++ b/src/org/labkey/test/components/ui/grids/EditableGrid.java @@ -15,6 +15,7 @@ import org.labkey.test.components.ui.entities.EntityBulkUpdateDialog; import org.labkey.test.params.FieldDefinition; import org.labkey.test.util.selenium.ScrollUtils; +import org.labkey.test.util.selenium.WebElementUtils; import org.openqa.selenium.By; import org.openqa.selenium.Keys; import org.openqa.selenium.NoSuchElementException; @@ -1136,7 +1137,7 @@ public List getColumnLabels() { for (WebElement el : headerCells) { - fieldLabels.add(el.getText().trim()); + fieldLabels.add(getLabelFromHeaderCell(el)); } int rowNumberColumn = 0; @@ -1155,6 +1156,35 @@ public List getColumnLabels() return fieldLabels; } + /** + * Extract label from header cell. Editable grid header cells have several different layouts. What they have in + * common is that the label is the first text node in the cell, possibly within a <span> + */ + private String getLabelFromHeaderCell(WebElement el) + { + // Use text nodes to ignore browser whitespace formatting + List textNodes = WebElementUtils.getTextNodesWithin(el); + if (textNodes.isEmpty()) + { + List children = Locator.xpath("./*").findElements(el); + if (children.isEmpty()) + { + return ""; // probably the selection checkbox column + } + else + { + // Depth-first search until we find some text + return getLabelFromHeaderCell(children.get(0)); + } + } + else + { + boolean required = Locator.byClass("required-symbol").existsIn(el); + String label = textNodes.get(0).trim(); // trim trailing NBSP + return label + (required ? " *" : ""); // re-add required asterisk for tests that expect it + } + } + public WebElement inputCell() { return Locators.inputCell.refindWhenNeeded(table); diff --git a/src/org/labkey/test/components/ui/grids/FieldSelectionDialog.java b/src/org/labkey/test/components/ui/grids/FieldSelectionDialog.java index 7e31e9036b..424114d6be 100644 --- a/src/org/labkey/test/components/ui/grids/FieldSelectionDialog.java +++ b/src/org/labkey/test/components/ui/grids/FieldSelectionDialog.java @@ -7,6 +7,7 @@ import org.labkey.test.components.UpdatingComponent; import org.labkey.test.components.bootstrap.ModalDialog; import org.labkey.test.components.html.Checkbox; +import org.labkey.test.util.selenium.WebElementUtils; import org.openqa.selenium.Keys; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebElement; @@ -20,6 +21,8 @@ import java.util.List; import java.util.stream.Collectors; +import static org.labkey.test.util.selenium.WebElementUtils.getTextContent; + /** * Wraps ColumnSelectionModal.tsx in UI components. */ @@ -88,7 +91,7 @@ public boolean isShowAllChecked() public List getAvailableFieldLabels() { List listItemElements = elementCache().getListItemNameElements(elementCache().availableFieldsPanel); - return listItemElements.stream().map(WebElement::getText).collect(Collectors.toList()); + return listItemElements.stream().map(WebElementUtils::getTextContent).collect(Collectors.toList()); } /** @@ -240,7 +243,7 @@ private boolean isFieldKeyExpanded(WebElement listItem) public List getSelectedFieldLabels() { List listItemElements = elementCache().getListItemNameElements(elementCache().selectedFieldsPanel); - return listItemElements.stream().map(WebElement::getText).collect(Collectors.toList()); + return listItemElements.stream().map(WebElementUtils::getTextContent).collect(Collectors.toList()); } /** @@ -256,7 +259,7 @@ public String getActiveSelectedFieldLabel() if(active.isDisplayed()) { - return Locator.tagWithClass("div", "field-name").findElement(active).getText(); + return getTextContent(Locator.tagWithClass("div", "field-name").findElement(active)); } else { diff --git a/src/org/labkey/test/components/ui/grids/GridFilterModal.java b/src/org/labkey/test/components/ui/grids/GridFilterModal.java index 5beb6814d8..2ffd46c9ad 100644 --- a/src/org/labkey/test/components/ui/grids/GridFilterModal.java +++ b/src/org/labkey/test/components/ui/grids/GridFilterModal.java @@ -7,6 +7,7 @@ import org.labkey.test.components.react.Tabs; import org.labkey.test.components.ui.search.FilterExpressionPanel; import org.labkey.test.components.ui.search.FilterFacetedPanel; +import org.labkey.test.util.selenium.WebElementUtils; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebElement; import org.openqa.selenium.support.ui.ExpectedConditions; @@ -67,7 +68,7 @@ public GridFilterModal checkNoDataCheckbox(boolean checked) */ public List getAvailableFieldLabels() { - return getWrapper().getTexts(elementCache().findFieldOptions()); + return elementCache().findFieldOptions().stream().map(WebElementUtils::getTextContent).collect(Collectors.toList()); } public List getFilteredFieldLabels() @@ -76,7 +77,7 @@ public List getFilteredFieldLabels() Locator.tagWithClass("span", "field-modal__field_dot")) .findElements(elementCache().fieldsSelectionPanel); - return filteredElements.stream().map(WebElement::getText).collect(Collectors.toList()); + return filteredElements.stream().map(WebElementUtils::getTextContent).collect(Collectors.toList()); } /** diff --git a/src/org/labkey/test/components/ui/grids/QueryGrid.java b/src/org/labkey/test/components/ui/grids/QueryGrid.java index f8c52acbd1..78e6eb21eb 100644 --- a/src/org/labkey/test/components/ui/grids/QueryGrid.java +++ b/src/org/labkey/test/components/ui/grids/QueryGrid.java @@ -14,7 +14,7 @@ import org.labkey.test.components.react.QueryChartPanel; import org.labkey.test.components.react.ReactCheckBox; import org.labkey.test.components.ui.FilterStatusValue; -import org.labkey.test.util.selenium.WebDriverUtils; +import org.labkey.test.util.selenium.WebElementUtils; import org.openqa.selenium.Keys; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebElement; @@ -587,7 +587,7 @@ public String getViewName() if(panelHeader.isDisplayed()) { // The view name in the header is not in a separate element. - viewName = WebDriverUtils.getTextNodeWithin(panelHeader); + viewName = WebElementUtils.getTextNodeWithin(panelHeader); } else { diff --git a/src/org/labkey/test/components/ui/grids/ResponsiveGrid.java b/src/org/labkey/test/components/ui/grids/ResponsiveGrid.java index 1acedf682f..70d7aefab9 100644 --- a/src/org/labkey/test/components/ui/grids/ResponsiveGrid.java +++ b/src/org/labkey/test/components/ui/grids/ResponsiveGrid.java @@ -15,6 +15,7 @@ import org.labkey.test.components.html.RadioButton; import org.labkey.test.components.react.ReactCheckBox; import org.labkey.test.components.ui.search.FilterExpressionPanel; +import org.labkey.test.util.selenium.WebElementUtils; import org.openqa.selenium.Keys; import org.openqa.selenium.NoSuchElementException; import org.openqa.selenium.NotFoundException; @@ -318,7 +319,7 @@ public void editColumnLabel(String fieldLabel, String newColumnLabel) .until(ExpectedConditions.stalenessOf(textEdit)); doAndWaitForUpdate(()-> - WebDriverWrapper.waitFor(()->headerCell.getText().equals(newColumnLabel), + WebDriverWrapper.waitFor(()-> WebElementUtils.getTextContent(headerCell).equals(newColumnLabel), "Column header not updated.", 1_000) ); waitForLoaded(); @@ -829,7 +830,7 @@ protected Map initColumnsAndIndices() headerCellElements.remove(0); offset = 1; } - fieldLabels = getWrapper().getTexts(headerCellElements); + fieldLabels = headerCellElements.stream().map(el -> WebElementUtils.getTextContent(el).trim()).toList(); indexes = new HashMap<>(); for (int i = 0; i < headerCellElements.size(); i++) { diff --git a/src/org/labkey/test/components/ui/grids/TabbedGridPanel.java b/src/org/labkey/test/components/ui/grids/TabbedGridPanel.java index 8c24c3f042..beb2d0cbe6 100644 --- a/src/org/labkey/test/components/ui/grids/TabbedGridPanel.java +++ b/src/org/labkey/test/components/ui/grids/TabbedGridPanel.java @@ -9,6 +9,7 @@ import org.openqa.selenium.WebElement; import org.openqa.selenium.support.ui.ExpectedConditions; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -47,7 +48,15 @@ public WebDriver getDriver() public List getTabs() { - return getWrapper().getTexts(elementCache().navTabs()); + List tabElements = elementCache().navTabs(); + List tabLabels = new ArrayList<>(); + //noinspection ResultOfMethodCallIgnored + WebDriverWrapper.waitFor(() -> { + tabLabels.clear(); + tabLabels.addAll(tabElements.stream().map(WebElement::getText).toList()); + return tabLabels.stream().noneMatch(String::isBlank); + }, 5_000); + return tabLabels; } public List getTabsWithoutCounts() diff --git a/src/org/labkey/test/pages/ReactAssayDesignerPage.java b/src/org/labkey/test/pages/ReactAssayDesignerPage.java index e2aa200093..4c5c66ad44 100644 --- a/src/org/labkey/test/pages/ReactAssayDesignerPage.java +++ b/src/org/labkey/test/pages/ReactAssayDesignerPage.java @@ -29,6 +29,7 @@ import org.labkey.test.components.ui.files.AttachmentCard; import org.labkey.test.pages.assay.plate.PlateTemplateListPage; import org.labkey.test.util.Maps; +import org.labkey.test.util.selenium.WebElementUtils; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebElement; import org.openqa.selenium.support.ui.Select; @@ -237,7 +238,7 @@ public HitSelectionDialog clickEditCriteria() public List getHitCriteria() { expandPropertiesPanel(); - return getWrapper().getTexts(elementCache().hitSelectionCriteriaLoc.findElements(elementCache().propertiesPanel)); + return elementCache().hitSelectionCriteriaLoc.findElements(elementCache().propertiesPanel).stream().map(WebElementUtils::getTextContent).toList(); } public ReactAssayDesignerPage setStatus(boolean checked) diff --git a/src/org/labkey/test/params/FieldDefinition.java b/src/org/labkey/test/params/FieldDefinition.java index effdec74fa..7c6717c9ee 100644 --- a/src/org/labkey/test/params/FieldDefinition.java +++ b/src/org/labkey/test/params/FieldDefinition.java @@ -16,6 +16,7 @@ package org.labkey.test.params; import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.json.JSONArray; import org.json.JSONObject; @@ -31,6 +32,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import static org.labkey.test.util.TestDataGenerator.DOMAIN_SPECIAL_STRING; @@ -60,7 +62,7 @@ public class FieldDefinition extends PropertyDescriptor * @param name field name * @param type field type */ - public FieldDefinition(String name, ColumnType type) + public FieldDefinition(@NotNull String name, @NotNull ColumnType type) { setName(name); setType(type); @@ -85,18 +87,13 @@ public FieldDefinition(String name) this(name, ColumnType.String); } - public static String labelFromName(String name) - { - return labelFromName(name, true); - } - // See BaseColumnInfo.labelFromName - public static String labelFromName(String name, boolean collapseSpaces) + public static String labelFromName(String name) { if (name == null) return null; - if (name.length() == 0) + if (name.isEmpty()) return name; StringBuilder buf = new StringBuilder(name.length() + 10); @@ -123,15 +120,14 @@ else if (Character.isUpperCase(c) && Character.isLowerCase(chars[i - 1])) } } - if (collapseSpaces) - { - // This differs from BaseColumnInfo.labelForName because for testing purposes - // we need the label as shown in the UI, which will contract multiple spaces - return buf.toString().replaceAll("\\s+", " "); - } return buf.toString(); } + public String getEffectiveLabel() + { + return Objects.requireNonNullElseGet(getLabel(), () -> labelFromName(getName())); + } + @Override public Map getAllProperties() { diff --git a/src/org/labkey/test/tests/SampleTypeNameExpressionTest.java b/src/org/labkey/test/tests/SampleTypeNameExpressionTest.java index 85c58808e4..5bff97c621 100644 --- a/src/org/labkey/test/tests/SampleTypeNameExpressionTest.java +++ b/src/org/labkey/test/tests/SampleTypeNameExpressionTest.java @@ -38,6 +38,7 @@ import org.labkey.test.util.PortalHelper; import org.labkey.test.util.SampleTypeHelper; import org.labkey.test.util.TestDataGenerator; +import org.labkey.test.util.TextUtils; import org.labkey.test.util.exp.SampleTypeAPIHelper; import org.openqa.selenium.TimeoutException; import org.openqa.selenium.WebElement; @@ -696,7 +697,7 @@ private String generateExpectedToolTip(@Nullable String expectedPreview) if(expectedPreview != null) { expectedToolTip.append("Example of name that will be generated from the current pattern: "); - expectedToolTip.append(expectedPreview); + expectedToolTip.append(TextUtils.normalizeSpace(expectedPreview)); expectedToolTip.append("\n"); } diff --git a/src/org/labkey/test/util/EscapeUtil.java b/src/org/labkey/test/util/EscapeUtil.java index 9e053a709e..82a67be918 100644 --- a/src/org/labkey/test/util/EscapeUtil.java +++ b/src/org/labkey/test/util/EscapeUtil.java @@ -119,28 +119,17 @@ public static String decodeUriPath(String path) return URIUtil.decodePath(path); } - public static String fieldKeyEncodePart(String str) + private static final String[] ILLEGAL = {"$", "/", "&", "}", "~", ",", "."}; + private static final String[] REPLACEMENT = {"$D", "$S", "$A", "$B", "$T", "$C", "$P"}; + + static public String fieldKeyEncodePart(String str) { - str = StringUtils.replace(str, "$", "$D"); - str = StringUtils.replace(str, "/", "$S"); - str = StringUtils.replace(str, "&", "$A"); - str = StringUtils.replace(str, "}", "$B"); - str = StringUtils.replace(str, "~", "$T"); - str = StringUtils.replace(str, ",", "$C"); - str = StringUtils.replace(str, ".", "$P"); - return str; + return StringUtils.replaceEach(str, ILLEGAL, REPLACEMENT); } - public static String fieldKeyDecodePart(String str) + static public String fieldKeyDecodePart(String str) { - str = StringUtils.replace(str, "$C", ","); - str = StringUtils.replace(str, "$T", "~"); - str = StringUtils.replace(str, "$B", "}"); - str = StringUtils.replace(str, "$A", "&"); - str = StringUtils.replace(str, "$S", "/"); - str = StringUtils.replace(str, "$D", "$"); - str = StringUtils.replace(str, "$P", "."); - return str; + return StringUtils.replaceEach(str, REPLACEMENT, ILLEGAL); } public static String getTextChoiceValidatorExpression(List options) diff --git a/src/org/labkey/test/util/TestDataGenerator.java b/src/org/labkey/test/util/TestDataGenerator.java index d99a5f57e9..b624fa2555 100644 --- a/src/org/labkey/test/util/TestDataGenerator.java +++ b/src/org/labkey/test/util/TestDataGenerator.java @@ -523,13 +523,7 @@ public static String randomMultiLineString(int size, @Nullable String exclusion) */ public static String randomName(@NotNull String part, int numStartChars, int numEndChars, String charSet, @Nullable String exclusions) { - String name = randomString(numStartChars, exclusions, charSet) + part + randomString(numEndChars, exclusions, charSet); - - // Multiple spaces in the UI are collapsed into a single space so we collapse them here so we can find things by name. - // See Issue 52193 for details. - // If we need to test for handling of multiple spaces, we'll not use this generator. - name = name.trim().replaceAll("\\s+", " "); - return name; + return (randomString(numStartChars, exclusions, charSet) + part + randomString(numEndChars, exclusions, charSet)).trim(); } public static String randomDomainName() diff --git a/src/org/labkey/test/util/TestDataUtils.java b/src/org/labkey/test/util/TestDataUtils.java index dc6d164563..c6e9886e3e 100644 --- a/src/org/labkey/test/util/TestDataUtils.java +++ b/src/org/labkey/test/util/TestDataUtils.java @@ -1,12 +1,19 @@ package org.labkey.test.util; +import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.CSVPrinter; +import org.apache.commons.io.FileUtils; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; import org.labkey.serverapi.reader.TabLoader; +import org.labkey.test.TestFileUtils; import org.labkey.test.params.FieldDefinition; +import java.io.File; +import java.io.FileWriter; import java.io.IOException; import java.io.InputStream; +import java.io.StringWriter; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; @@ -144,13 +151,13 @@ public static List> rowMapsFromCsv(String tsvString) throws public static String tsvStringFromRowMaps(List> rowMaps, List columns, boolean includeHeaders) { - return toTabular(rowMaps, columns, '\t', includeHeaders); + return writeRowsToString(rowListsFromMaps(rowMaps, columns, includeHeaders, true), CSVFormat.TDF); } public static String csvStringFromRowMaps(List> rowMaps, List columns, boolean includeHeaders) { - return toTabular(rowMaps, columns, ',', includeHeaders); + return writeRowsToString(rowListsFromMaps(rowMaps, columns, includeHeaders, true), CSVFormat.DEFAULT); } @@ -164,7 +171,6 @@ public static List> rowListsFromMaps(List> rowM * @param rowMaps Source data * @param columns keys contained in each map, will copy values associated with them to the resulting list * @return A List> containing values - * @throws IOException */ public static List> rowListsFromMaps(List> rowMaps, List columns, boolean includeHeaders, boolean preserveEmptyValues) { @@ -172,65 +178,67 @@ public static List> rowListsFromMaps(List> rowM if (includeHeaders) { - List headers = new ArrayList<>(); - for(String col : columns) - headers.add(col); + List headers = new ArrayList<>(columns); lists.add(headers); } - for (int i=0; i rowMap : rowMaps) { List rowList = new ArrayList<>(); - var rowMap = rowMaps.get(i); - for(String column : columns) + for (String column : columns) { - var value = (String) rowMap.get(column); - if (value == null && preserveEmptyValues) - rowList.add(""); - else - rowList.add(value); + var value = rowMap.get(column); + if (value == null) + { + if (preserveEmptyValues) + value = ""; + else + throw new IllegalArgumentException("Missing value for column '" + column + "' in row: " + rowMap); + } + rowList.add(value.toString()); } lists.add(rowList); } return lists; } - /** - * Convert a list of Map> to tabluar (tsv, csv) format - * (assumes the rowMaps all share the same keyset/schema) - * can be used to generate edit-grid paste data, if delimiter is \t and includeHeaders is false - * - * @param rowMaps data to be written into tabular format - * @param columns the fields (in order) from the rowMaps to include in tabular output - * @param delimiter comma [,] for csv tab [\t] for tsv - * @param includeHeaders whether to write the keys as column names on the first line of the output string - * @return - */ - private static String toTabular(List> rowMaps, List columns, - char delimiter, boolean includeHeaders) + public static File writeRowsToTsv(String fileName, List> rows) throws IOException { - StringBuilder builder = new StringBuilder(); - TsvQuoter q = new TsvQuoter(delimiter); + File file = new File(TestFileUtils.getTestTempDir(), fileName); + FileUtils.forceMkdirParent(file); - if (includeHeaders) - { - builder.append(String.join(String.valueOf(delimiter), columns.stream().map(q::quoteValue).toList())); - builder.append("\n"); + try (CSVPrinter printer = new CSVPrinter(new FileWriter(file, StandardCharsets.UTF_8), CSVFormat.TDF)) { + for (List row : rows) + { + printer.printRecord(row); + } } - for (Map row : rowMaps) - { - List values = new ArrayList<>(); - for (String name : columns) + return file; + } + + public static String writeRowsToTsvString(List> rows) throws IOException + { + return writeRowsToString(rows, CSVFormat.TDF); + } + + public static String writeRowsToString(List> rows, CSVFormat format) + { + StringWriter stringWriter = new StringWriter(); + + try (CSVPrinter printer = new CSVPrinter(stringWriter, format)) { + for (List row : rows) { - String value = q.quoteValue(row.get(name)); - values.add(value); + printer.printRecord(row); } - builder.append(String.join(String.valueOf(delimiter), values)); - builder.append("\n"); } - return builder.toString(); + catch (IOException e) + { + throw new RuntimeException(e); + } + + return stringWriter.toString(); } /** diff --git a/src/org/labkey/test/util/TextUtils.java b/src/org/labkey/test/util/TextUtils.java new file mode 100644 index 0000000000..5b8a585cfd --- /dev/null +++ b/src/org/labkey/test/util/TextUtils.java @@ -0,0 +1,41 @@ +package org.labkey.test.util; + +import java.util.List; +import java.util.regex.Pattern; + +public class TextUtils +{ + private static final Pattern NS_PATTERN = Pattern.compile("\\s+"); + + private TextUtils() {} + + /** + * Equivalent to XPath {@code normalize-space()}:
+ * "The normalize-space function strips leading and trailing white-space from a string, replaces sequences of + * whitespace characters by a single space, and returns the resulting string." + */ + public static String normalizeSpace(String value) + { + return NS_PATTERN.matcher(value).replaceAll(" ").trim(); + } + + public static List normalizeSpace(List values) + { + return values.stream().map(TextUtils::normalizeSpace).toList(); + } + + public static String normalizeSpaceMultiline(String value) + { + String[] lines = value.split("\n"); + for (int i = 0; i < lines.length; i++) + { + lines[i] = normalizeSpace(lines[i]); + } + return String.join("\n", lines); + } + + public static List normalizeSpaceMultiline(List values) + { + return values.stream().map(TextUtils::normalizeSpaceMultiline).toList(); + } +} diff --git a/src/org/labkey/test/util/selenium/WebDriverUtils.java b/src/org/labkey/test/util/selenium/WebDriverUtils.java index b6306ef67f..eff8ec79bc 100644 --- a/src/org/labkey/test/util/selenium/WebDriverUtils.java +++ b/src/org/labkey/test/util/selenium/WebDriverUtils.java @@ -17,21 +17,14 @@ import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.SystemUtils; -import org.intellij.lang.annotations.Language; import org.openqa.selenium.Alert; -import org.openqa.selenium.JavascriptExecutor; import org.openqa.selenium.Keys; import org.openqa.selenium.NoAlertPresentException; -import org.openqa.selenium.NoSuchElementException; import org.openqa.selenium.UnhandledAlertException; import org.openqa.selenium.WebDriver; -import org.openqa.selenium.WebDriverException; -import org.openqa.selenium.WebElement; import org.openqa.selenium.WrapsDriver; import org.openqa.selenium.WrapsElement; -import java.util.List; - public abstract class WebDriverUtils { /** @@ -81,71 +74,6 @@ public static WebDriver extractWrappedDriver(Object peeling) return null; } - /** - * {@link WebElement} cannot represent a text node. JavaScript can though, so we can use it to isolate the text - * children of a WebElement and get their text. - * Given a WebElement representing the following div: - *
{@code
-     * 
- * A - * B - * - * D - * D - *
- * }
- * This method will return a list containing {@code ["B", "D"]} - * @param element element to search - * @return text from all child text nodes - */ - @SuppressWarnings("unchecked") - public static List getTextNodesWithin(WebElement element) - { - JavascriptExecutor executor = (JavascriptExecutor) extractWrappedDriver(element); - - @Language("JavaScript") - final String script = """ - var iterator = document.evaluate("text()", arguments[0]); - var texts = []; - - let thisNode = iterator.iterateNext(); - - while (thisNode) { - texts.push(thisNode.textContent); - thisNode = iterator.iterateNext(); - } - return texts; - """; - - List nodeTexts; - try - { - nodeTexts = (List) executor.executeScript(script, element); - } - catch (WebDriverException retry) - { - // Script might throw if the document tree is modified during iteration. Retry once. - nodeTexts = (List) executor.executeScript(script, element); - } - - return nodeTexts.stream().map(t -> (String) t).toList(); - } - - /** - * Gets text from the first text node under the specified WebElement. - * - * @see #getTextNodesWithin(WebElement) - */ - public static String getTextNodeWithin(WebElement element) - { - List textChildren = getTextNodesWithin(element); - if (textChildren.isEmpty()) - { - throw new NoSuchElementException("Element does not have any text children: " + element.toString()); - } - return textChildren.get(0); - } - /** * Attempts to get alert text from an {@link UnhandledAlertException}. If exception does not supply the alert text, * attempt to get it from the alert directly (requires {@link org.openqa.selenium.UnexpectedAlertBehaviour#IGNORE}). diff --git a/src/org/labkey/test/util/selenium/WebElementUtils.java b/src/org/labkey/test/util/selenium/WebElementUtils.java new file mode 100644 index 0000000000..d6866f73de --- /dev/null +++ b/src/org/labkey/test/util/selenium/WebElementUtils.java @@ -0,0 +1,98 @@ +package org.labkey.test.util.selenium; + +import org.intellij.lang.annotations.Language; +import org.openqa.selenium.JavascriptExecutor; +import org.openqa.selenium.NoSuchElementException; +import org.openqa.selenium.WebDriverException; +import org.openqa.selenium.WebElement; + +import java.util.Collections; +import java.util.List; + +import static org.labkey.test.Locator.NBSP; + +public abstract class WebElementUtils +{ + private WebElementUtils() {} + + /** + * {@link WebElement} cannot represent a text node. JavaScript can though, so we can use it to isolate the text + * children of a WebElement and get their text. + * Given a WebElement representing the following div: + *
{@code
+     * 
+ * A + * B + * + * D + * D + *
+ * }
+ * This method will return a list containing {@code ["B", "D"]} + * @param element element to search + * @return text from all child text nodes + */ + @SuppressWarnings("unchecked") + public static List getTextNodesWithin(WebElement element) + { + JavascriptExecutor executor = (JavascriptExecutor) WebDriverUtils.extractWrappedDriver(element); + + @Language("JavaScript") + final String script = """ + var iterator = document.evaluate("text()", arguments[0]); + var texts = []; + + let thisNode = iterator.iterateNext(); + + while (thisNode) { + texts.push(thisNode.textContent); + thisNode = iterator.iterateNext(); + } + return texts; + """; + + List nodeTexts; + try + { + nodeTexts = (List) executor.executeScript(script, element); + } + catch (WebDriverException retry) + { + // Script might throw if the document tree is modified during iteration. Retry once. + nodeTexts = (List) executor.executeScript(script, element); + } + + return nodeTexts != null ? nodeTexts.stream().map(t -> (String) t).toList() : Collections.emptyList(); + } + + /** + * Gets text from the first text node under the specified WebElement. + * + * @see #getTextNodesWithin(WebElement) + */ + public static String getTextNodeWithin(WebElement element) + { + List textChildren = getTextNodesWithin(element); + if (textChildren.isEmpty()) + { + throw new NoSuchElementException("Element does not have any text children: " + element.toString()); + } + return textChildren.get(0); + } + + /** + * {@link WebElement#getText()} matches the browser's rendering, which collapses and trims whitespace. + * If you need the actual text written by the server, the element's {@code textContent} property is unmodified.
+ * Given a WebElement representing the following div: + *
{@code
+     * 
three spaces
+ * }
+ * {@link WebElement#getText()} would return {@code "three spaces"} but this method will retain the extra spaces. + * @param element element to inspect + * @return textContent for the given element + */ + public static String getTextContent(WebElement element) + { + return element.getDomProperty("textContent").replace(NBSP, " "); + } +}