Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
package org.labkey.test.components.domain;

import org.labkey.test.Locator;
import org.labkey.test.WebDriverWrapper;
import org.labkey.test.components.bootstrap.ModalDialog;
import org.openqa.selenium.WebElement;

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

/**
* Modal that opens when the user clicks the "AI Assistant" button inside the Calculation field options.
*/
public class CalculatedColumnAssistantDialog extends ModalDialog
{
public static final String TITLE = "Expression AI Assistant";

private final DomainFieldRow _row;

public CalculatedColumnAssistantDialog(DomainFieldRow row, ModalDialogFinder finder)
{
super(finder);
_row = row;
}

public CalculatedColumnAssistantDialog(DomainFieldRow row)
{
this(row, new ModalDialogFinder(row.getDriver()).withTitle(TITLE));
}

/**
* Type the prompt into the textarea. The submit button stays disabled until non-empty text is present.
*/
public CalculatedColumnAssistantDialog setPrompt(String prompt)
{
getWrapper().setFormElement(elementCache().promptInput, prompt);
WebDriverWrapper.waitFor(() -> elementCache().promptSubmitButton.isEnabled(),
"Prompt submit button did not become enabled.", 2_000);
return this;
}

public String getPrompt()
{
return getWrapper().getFormElement(elementCache().promptInput);
}

/**
* Click the submit (arrow) button. First waits for the "Thinking..." spinner to disappear (up to 60s)
* and then for a new assistant response to render (up to 10s).
*/
public CalculatedColumnAssistantDialog submitPrompt()
{
int previousCount = getAssistantResponses().size();
elementCache().promptSubmitButton.click();
waitForThinkingSpinnerToDisappear();
WebDriverWrapper.waitFor(() -> getAssistantResponses().size() > previousCount,
"No new assistant response appeared in chat history.", 10_000);
return this;
}

private void waitForThinkingSpinnerToDisappear()
{
WebDriverWrapper.waitFor(() -> !Locators.thinkingSpinner.existsIn(this), 60_000);
}

/**
* Convenience: type the prompt and submit it.
*/
public CalculatedColumnAssistantDialog sendPrompt(String prompt)
{
return setPrompt(prompt).submitPrompt();
}

/**
* @return one entry per assistant response bubble (concatenated text of all its {@code .assistant-text} blocks),
* in chat order. Suggested-expression SQL is not included here — see {@link #getSuggestedExpressions()}.
*/
public List<String> getAssistantResponses()
{
return Locators.assistantResponse.findElements(this).stream()
.map(WebElement::getText)
.collect(Collectors.toList());
}

/**
* @return text of the most recent assistant response, or empty string if there are none.
*/
public String getLastAssistantResponse()
{
List<String> responses = getAssistantResponses();
return responses.isEmpty() ? "" : responses.get(responses.size() - 1);
}

/**
* @return every <em>applicable</em> SQL expression suggested in the most recent assistant response, in display
* order. Only counts {@code .assistant-expression} blocks that include an "Apply Expression" button — read-only
* SQL the assistant shows for illustration (e.g. an alternative custom-query example) is excluded, since the user
* can't accept it as the field's calculation.
*/
public List<String> getSuggestedExpressions()
{
WebElement lastResponse = lastAssistantResponseElement();
if (lastResponse == null)
return List.of();
return Locators.applicableSqlCode.findElements(lastResponse).stream()
.map(WebElement::getText)
.collect(Collectors.toList());
}

/**
* @return the first SQL expression in the most recent assistant response, or empty string if none.
*/
public String getFirstSuggestedExpression()
{
List<String> expressions = getSuggestedExpressions();
return expressions.isEmpty() ? "" : expressions.get(0);
}

/**
* Click "Apply Expression" on the first suggestion in the most recent assistant response.
* Returns the underlying field row (the dialog stays open; call {@link #clickEndChat()} to close it).
*/
public DomainFieldRow applyFirstSuggestedExpression()
{
return applySuggestedExpression(0);
}

/**
* Click "Apply Expression" on the suggestion at the given index in the most recent assistant response. Waits
* up to 5 seconds for at least one applicable expression to render — the spinner disappears as soon as the
* bubble exists, but the inner {@code assistant-expression} block sometimes finishes rendering a moment later.
*/
public DomainFieldRow applySuggestedExpression(int index)
{
List<WebElement> buttons = new ArrayList<>();
WebDriverWrapper.waitFor(() -> {
buttons.clear();
WebElement last = lastAssistantResponseElement();
if (last != null)
buttons.addAll(Locators.applyButton.findElements(last));
return !buttons.isEmpty();
},
"No applicable expression rendered in the assistant response.",
5_000);

if (index >= buttons.size())
throw new IndexOutOfBoundsException(
"Requested expression index " + index + " but only " + buttons.size() + " expression(s) available.");
buttons.get(index).click();
return _row;
}

/**
* @return text of the first assistant response in the chat history, or empty string if there are none. Useful
* for asserting the intro message in NEW / CHANGE / VALIDATE entry modes.
*/
public String getFirstAssistantResponse()
{
List<String> responses = getAssistantResponses();
return responses.isEmpty() ? "" : responses.get(0);
}

/**
* @return true while the dialog is waiting for an AI response (the "Thinking..." pending bubble is shown).
*/
public boolean isPending()
{
return Locators.pendingBubble.existsIn(this);
}

/**
* Click the stop button to abort an in-flight AI request. The submit button toggles to a stop button (fa-stop)
* while the dialog is in the pending state; calling this method when no request is pending will fail.
*/
public void clickStop()
{
Locators.stopButton.findElement(this).click();
}

/**
* Click submit without waiting for the response. Useful for tests that need to interrupt or otherwise observe
* the pending state before the response arrives. Prefer {@link #submitPrompt()} when the caller wants to wait.
*/
public void clickSubmitWithoutWaiting()
{
elementCache().promptSubmitButton.click();
}

private WebElement lastAssistantResponseElement()
{
List<WebElement> responses = Locators.assistantResponse.findElements(this);
return responses.isEmpty() ? null : responses.get(responses.size() - 1);
}

/**
* Click "End Chat" to close the dialog.
*/
public DomainFieldRow clickEndChat()
{
elementCache().endChatButton.click();
waitForClose();
return _row;
}

@Override
protected ElementCache newElementCache()
{
return new ElementCache();
}

@Override
protected ElementCache elementCache()
{
return (ElementCache) super.elementCache();
}

public static class Locators
{
public static final Locator.XPathLocator assistantResponse = Locator.tagWithClass("div", "chat-item").withClass("assistant-response");

public static final Locator.XPathLocator pendingBubble = Locator.tagWithClass("div", "chat-item").withClass("pending");

public static final Locator.XPathLocator thinkingSpinner = Locator.tagWithClass("i", "fa-spinner");

public static final Locator.XPathLocator applyButton = Locator.tagWithClass("div", "assistant-expression")
.descendant(Locator.tagWithClass("button", "clickable-text"));

public static final Locator.XPathLocator applicableSqlCode = Locator.tagWithClass("div", "assistant-expression")
.withDescendant(Locator.tagWithClass("button", "clickable-text"))
.descendant(Locator.tag("code"));

public static final Locator.XPathLocator stopButton = Locator.tagWithClass("button", "prompt-button")
.withDescendant(Locator.tagWithClass("i", "fa-stop"));
}

protected class ElementCache extends ModalDialog.ElementCache
{
final WebElement endChatButton = Locator.tagWithClass("button", "btn").withText("End Chat").findWhenNeeded(this);

final WebElement promptInput = Locator.tagWithClass("textarea", "prompt-input").findWhenNeeded(this);

final WebElement promptSubmitButton = Locator.tagWithClass("button", "prompt-button").refindWhenNeeded(this);
}
}
23 changes: 23 additions & 0 deletions src/org/labkey/test/components/domain/DomainFieldRow.java
Original file line number Diff line number Diff line change
Expand Up @@ -1085,6 +1085,28 @@ public String getValueExpression()
return getWrapper().getFormElement(elementCache().expressionInput);
}

/**
* Click the "AI Assistant" button in the expanded Calculation field options and return the resulting dialog.
*/
public CalculatedColumnAssistantDialog openAIAssistant()
{
expand();
elementCache().aiAssistantButton.click();
return new CalculatedColumnAssistantDialog(this);
}

/**
* @return true if the "AI Assistant" button is present in the expanded Calculation field options.
* The button is only available when the {@code professional} module is enabled.
*/
public boolean hasAIAssistantButton()
{
expand();
return Locator.tagWithClass("button", "btn")
.withText("AI Assistant")
.findElementOrNull(this) != null;
}

// advanced settings

public DomainFieldRow showFieldOnDefaultView(boolean checked)
Expand Down Expand Up @@ -1778,6 +1800,7 @@ protected class ElementCache extends WebDriverComponent.ElementCache
public final WebElement expressionStatusError = expressionStatusMsgLoc.descendant(Locator.tagWithClass("span", "error")).refindWhenNeeded(this);
public final WebElement expressionStatusMsg = expressionStatusMsgLoc.childTag("div").refindWhenNeeded(this);
public final WebElement expressionValidateLink = expressionStatusMsgLoc.child(Locator.tagWithClass("div", "validate-link")).refindWhenNeeded(this);
public final WebElement aiAssistantButton = Locator.tagWithClass("button", "btn").withText("AI Assistant").refindWhenNeeded(this);

Locator.XPathLocator aliquotWarningAlert = Locator.tagWithClassContaining("div", "aliquot-alert-warning");

Expand Down
2 changes: 1 addition & 1 deletion src/org/labkey/test/pages/ReactAssayDesignerPage.java
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ private ReactAssayDesignerPage setTransformScript(File transformScript, boolean
{
getWrapper().waitFor(()-> Locator.tagWithClass("div", "alert-danger").withText(expectedError).isDisplayed(this),
"Transform script expected error not found", WAIT_FOR_JAVASCRIPT);
getWrapper().click(Locator.tagWithClass("i", "container--removal-icon"));
getWrapper().click(Locator.tagWithClass("span", "container--removal-icon"));
}

return this;
Expand Down
2 changes: 1 addition & 1 deletion src/org/labkey/test/pages/reports/ScriptReportPage.java
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ private void _clickReportTab()
scrollToTop(); // Clicking report tab can scroll such that the cursor hovers over and opens the project menu
waitAndClick(Ext4Helper.Locators.tab("Report"));
// Report view should appear quickly
shortWait().until(ExpectedConditions.visibilityOfElementLocated(Locator.tagWithClass("div", "reportView")));
longWait().until(ExpectedConditions.visibilityOfElementLocated(Locator.tagWithClass("div", "reportView")));
// Actual report might take a while to load
_ext4Helper.waitForMaskToDisappear(BaseWebDriverTest.WAIT_FOR_PAGE);
}
Expand Down
2 changes: 1 addition & 1 deletion src/org/labkey/test/tests/AbstractKnitrReportTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ protected void moduleReportDependencies()
clickProject(getProjectName());
_ext4Helper.waitForMaskToDisappear();
waitAndClickAndWait(Locator.linkWithText("kable"));
_ext4Helper.waitForMaskToDisappear(3 * BaseWebDriverTest.WAIT_FOR_JAVASCRIPT);
_ext4Helper.waitForMaskToDisappear(60_000);
waitForElement(Locator.id("mtcars_table"));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ public void testUpload200kRows() throws Exception

// wait for import complete
var assayJobsPage1 = new AssayUploadJobsPage(getDriver());
var pipelineDetailsPage1 = assayJobsPage1.clickJobStatus("200k", 3 * WebDriverWrapper.WAIT_FOR_PAGE);
var pipelineDetailsPage1 = assayJobsPage1.clickJobStatus("200k", 6 * WebDriverWrapper.WAIT_FOR_PAGE);
pipelineDetailsPage1.waitForComplete(12 * WebDriverWrapper.WAIT_FOR_PAGE);

// export assay1 data to excel
Expand Down