From be9d2e13c7572d8fa84db2da45334ecc74eb7ffe Mon Sep 17 00:00:00 2001 From: Daria Bodiakova <70635654+DariaBod@users.noreply.github.com> Date: Fri, 22 May 2026 15:27:04 -0700 Subject: [PATCH 1/5] create calculated column assistant modal create first test create button in DomainFieldRow --- .../CalculatedColumnAssistantDialog.java | 180 ++++++++++++++++++ .../components/domain/DomainFieldRow.java | 12 ++ 2 files changed, 192 insertions(+) create mode 100644 src/org/labkey/test/components/domain/CalculatedColumnAssistantDialog.java diff --git a/src/org/labkey/test/components/domain/CalculatedColumnAssistantDialog.java b/src/org/labkey/test/components/domain/CalculatedColumnAssistantDialog.java new file mode 100644 index 0000000000..ca94aa9f17 --- /dev/null +++ b/src/org/labkey/test/components/domain/CalculatedColumnAssistantDialog.java @@ -0,0 +1,180 @@ +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.List; +import java.util.stream.Collectors; + +/** + * Modal that opens when the user clicks the "AI Assistant" button inside the Calculation field options. + * Provides a chat-style interface where the user enters prompts and the assistant suggests expressions. + */ +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() + { + Locator spinner = Locator.tagWithClass("i", "fa-spinner"); + // Spinner may not appear if the response is instantaneous; that's fine. + WebDriverWrapper.waitFor(() -> !spinner.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 getAssistantResponses() + { + return Locator.tagWithClass("div", "chat-item").withClass("assistant-response") + .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 responses = getAssistantResponses(); + return responses.isEmpty() ? "" : responses.get(responses.size() - 1); + } + + /** + * @return every SQL expression suggested in the most recent assistant response, in display order. + * Usually a single entry, occasionally more. + */ + public List getSuggestedExpressions() + { + WebElement lastResponse = lastAssistantResponseElement(); + if (lastResponse == null) + return List.of(); + return Locator.tagWithClass("div", "assistant-expression") + .descendant(Locator.tag("code")) + .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 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() + { + WebElement lastResponse = lastAssistantResponseElement(); + if (lastResponse == null) + throw new IllegalStateException("No assistant response is available to apply."); + Locator.tagWithClass("div", "assistant-expression") + .descendant(Locator.tagWithClass("button", "clickable-text")) + .findElement(lastResponse) + .click(); + return _row; + } + + private WebElement lastAssistantResponseElement() + { + List responses = Locator.tagWithClass("div", "chat-item").withClass("assistant-response") + .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(); + } + + 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") + .findWhenNeeded(this); + } +} diff --git a/src/org/labkey/test/components/domain/DomainFieldRow.java b/src/org/labkey/test/components/domain/DomainFieldRow.java index 107df90f36..d05c6fc9aa 100644 --- a/src/org/labkey/test/components/domain/DomainFieldRow.java +++ b/src/org/labkey/test/components/domain/DomainFieldRow.java @@ -1070,6 +1070,17 @@ 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() + { + if (!isExpanded()) + expand(); + elementCache().aiAssistantButton.click(); + return new CalculatedColumnAssistantDialog(this); + } + // advanced settings public DomainFieldRow showFieldOnDefaultView(boolean checked) @@ -1763,6 +1774,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"); From 9a2d26859a9afaa04875f1028630ec765dab4c1f Mon Sep 17 00:00:00 2001 From: Daria Bodiakova <70635654+DariaBod@users.noreply.github.com> Date: Fri, 29 May 2026 16:44:38 -0700 Subject: [PATCH 2/5] tests for Calculation field's "AI Assistant" --- .../CalculatedColumnAssistantDialog.java | 69 ++++++++++++++++--- .../components/domain/DomainFieldRow.java | 13 ++++ 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/src/org/labkey/test/components/domain/CalculatedColumnAssistantDialog.java b/src/org/labkey/test/components/domain/CalculatedColumnAssistantDialog.java index ca94aa9f17..4556966f28 100644 --- a/src/org/labkey/test/components/domain/CalculatedColumnAssistantDialog.java +++ b/src/org/labkey/test/components/domain/CalculatedColumnAssistantDialog.java @@ -96,8 +96,10 @@ public String getLastAssistantResponse() } /** - * @return every SQL expression suggested in the most recent assistant response, in display order. - * Usually a single entry, occasionally more. + * @return every applicable 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 getSuggestedExpressions() { @@ -105,6 +107,7 @@ public List getSuggestedExpressions() if (lastResponse == null) return List.of(); return Locator.tagWithClass("div", "assistant-expression") + .withDescendant(Locator.tagWithClass("button", "clickable-text")) .descendant(Locator.tag("code")) .findElements(lastResponse).stream() .map(WebElement::getText) @@ -125,17 +128,67 @@ public String getFirstSuggestedExpression() * 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. + */ + public DomainFieldRow applySuggestedExpression(int index) { WebElement lastResponse = lastAssistantResponseElement(); if (lastResponse == null) throw new IllegalStateException("No assistant response is available to apply."); - Locator.tagWithClass("div", "assistant-expression") + List buttons = Locator.tagWithClass("div", "assistant-expression") .descendant(Locator.tagWithClass("button", "clickable-text")) - .findElement(lastResponse) - .click(); + .findElements(lastResponse); + if (index < 0 || 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 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 Locator.tagWithClass("div", "chat-item").withClass("pending").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() + { + Locator.tagWithClass("button", "prompt-button") + .withDescendant(Locator.tagWithClass("i", "fa-stop")) + .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 responses = Locator.tagWithClass("div", "chat-item").withClass("assistant-response") @@ -169,12 +222,12 @@ protected class ElementCache extends ModalDialog.ElementCache { final WebElement endChatButton = Locator.tagWithClass("button", "btn") .withText("End Chat") - .findWhenNeeded(this); + .refindWhenNeeded(this); final WebElement promptInput = Locator.tagWithClass("textarea", "prompt-input") - .findWhenNeeded(this); + .refindWhenNeeded(this); final WebElement promptSubmitButton = Locator.tagWithClass("button", "prompt-button") - .findWhenNeeded(this); + .refindWhenNeeded(this); } } diff --git a/src/org/labkey/test/components/domain/DomainFieldRow.java b/src/org/labkey/test/components/domain/DomainFieldRow.java index 9762f96ba8..b52fb6ec43 100644 --- a/src/org/labkey/test/components/domain/DomainFieldRow.java +++ b/src/org/labkey/test/components/domain/DomainFieldRow.java @@ -1096,6 +1096,19 @@ public CalculatedColumnAssistantDialog openAIAssistant() 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() + { + if (!isExpanded()) + expand(); + return Locator.tagWithClass("button", "btn") + .withText("AI Assistant") + .findElementOrNull(this) != null; + } + // advanced settings public DomainFieldRow showFieldOnDefaultView(boolean checked) From a29e5c3dba6c0764af38be0927d407159724f31f Mon Sep 17 00:00:00 2001 From: Daria Bodiakova <70635654+DariaBod@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:25:17 -0700 Subject: [PATCH 3/5] fix tests --- .../CalculatedColumnAssistantDialog.java | 76 +++++++++++-------- 1 file changed, 44 insertions(+), 32 deletions(-) diff --git a/src/org/labkey/test/components/domain/CalculatedColumnAssistantDialog.java b/src/org/labkey/test/components/domain/CalculatedColumnAssistantDialog.java index 4556966f28..168f0113ac 100644 --- a/src/org/labkey/test/components/domain/CalculatedColumnAssistantDialog.java +++ b/src/org/labkey/test/components/domain/CalculatedColumnAssistantDialog.java @@ -5,12 +5,12 @@ 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. - * Provides a chat-style interface where the user enters prompts and the assistant suggests expressions. */ public class CalculatedColumnAssistantDialog extends ModalDialog { @@ -61,9 +61,7 @@ public CalculatedColumnAssistantDialog submitPrompt() private void waitForThinkingSpinnerToDisappear() { - Locator spinner = Locator.tagWithClass("i", "fa-spinner"); - // Spinner may not appear if the response is instantaneous; that's fine. - WebDriverWrapper.waitFor(() -> !spinner.existsIn(this), 60_000); + WebDriverWrapper.waitFor(() -> !Locators.thinkingSpinner.existsIn(this), 60_000); } /** @@ -80,8 +78,7 @@ public CalculatedColumnAssistantDialog sendPrompt(String prompt) */ public List getAssistantResponses() { - return Locator.tagWithClass("div", "chat-item").withClass("assistant-response") - .findElements(this).stream() + return Locators.assistantResponse.findElements(this).stream() .map(WebElement::getText) .collect(Collectors.toList()); } @@ -106,10 +103,7 @@ public List getSuggestedExpressions() WebElement lastResponse = lastAssistantResponseElement(); if (lastResponse == null) return List.of(); - return Locator.tagWithClass("div", "assistant-expression") - .withDescendant(Locator.tagWithClass("button", "clickable-text")) - .descendant(Locator.tag("code")) - .findElements(lastResponse).stream() + return Locators.applicableSqlCode.findElements(lastResponse).stream() .map(WebElement::getText) .collect(Collectors.toList()); } @@ -133,17 +127,24 @@ public DomainFieldRow applyFirstSuggestedExpression() } /** - * Click "Apply Expression" on the suggestion at the given index in the most recent assistant response. + * 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) { - WebElement lastResponse = lastAssistantResponseElement(); - if (lastResponse == null) - throw new IllegalStateException("No assistant response is available to apply."); - List buttons = Locator.tagWithClass("div", "assistant-expression") - .descendant(Locator.tagWithClass("button", "clickable-text")) - .findElements(lastResponse); - if (index < 0 || index >= buttons.size()) + List 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(); @@ -165,7 +166,7 @@ public String getFirstAssistantResponse() */ public boolean isPending() { - return Locator.tagWithClass("div", "chat-item").withClass("pending").existsIn(this); + return Locators.pendingBubble.existsIn(this); } /** @@ -174,10 +175,7 @@ public boolean isPending() */ public void clickStop() { - Locator.tagWithClass("button", "prompt-button") - .withDescendant(Locator.tagWithClass("i", "fa-stop")) - .findElement(this) - .click(); + Locators.stopButton.findElement(this).click(); } /** @@ -191,8 +189,7 @@ public void clickSubmitWithoutWaiting() private WebElement lastAssistantResponseElement() { - List responses = Locator.tagWithClass("div", "chat-item").withClass("assistant-response") - .findElements(this); + List responses = Locators.assistantResponse.findElements(this); return responses.isEmpty() ? null : responses.get(responses.size() - 1); } @@ -218,16 +215,31 @@ 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") - .refindWhenNeeded(this); + final WebElement endChatButton = Locator.tagWithClass("button", "btn").withText("End Chat").refindWhenNeeded(this); - final WebElement promptInput = Locator.tagWithClass("textarea", "prompt-input") - .refindWhenNeeded(this); + final WebElement promptInput = Locator.tagWithClass("textarea", "prompt-input").refindWhenNeeded(this); - final WebElement promptSubmitButton = Locator.tagWithClass("button", "prompt-button") - .refindWhenNeeded(this); + final WebElement promptSubmitButton = Locator.tagWithClass("button", "prompt-button").refindWhenNeeded(this); } } From 56f12beb782d6418b1cd61a58ee49b6857e731d6 Mon Sep 17 00:00:00 2001 From: Daria Bodiakova <70635654+DariaBod@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:51:56 -0700 Subject: [PATCH 4/5] Apply suggestions from code review Co-authored-by: Trey Chadick --- .../components/domain/CalculatedColumnAssistantDialog.java | 6 +++--- src/org/labkey/test/components/domain/DomainFieldRow.java | 6 ++---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/org/labkey/test/components/domain/CalculatedColumnAssistantDialog.java b/src/org/labkey/test/components/domain/CalculatedColumnAssistantDialog.java index 168f0113ac..fb4a495fb3 100644 --- a/src/org/labkey/test/components/domain/CalculatedColumnAssistantDialog.java +++ b/src/org/labkey/test/components/domain/CalculatedColumnAssistantDialog.java @@ -236,10 +236,10 @@ public static class Locators protected class ElementCache extends ModalDialog.ElementCache { - final WebElement endChatButton = Locator.tagWithClass("button", "btn").withText("End Chat").refindWhenNeeded(this); + final WebElement endChatButton = Locator.tagWithClass("button", "btn").withText("End Chat").findWhenNeeded(this); - final WebElement promptInput = Locator.tagWithClass("textarea", "prompt-input").refindWhenNeeded(this); + final WebElement promptInput = Locator.tagWithClass("textarea", "prompt-input").findWhenNeeded(this); - final WebElement promptSubmitButton = Locator.tagWithClass("button", "prompt-button").refindWhenNeeded(this); + final WebElement promptSubmitButton = Locator.tagWithClass("button", "prompt-button").findWhenNeeded(this); } } diff --git a/src/org/labkey/test/components/domain/DomainFieldRow.java b/src/org/labkey/test/components/domain/DomainFieldRow.java index b52fb6ec43..be6ed3774d 100644 --- a/src/org/labkey/test/components/domain/DomainFieldRow.java +++ b/src/org/labkey/test/components/domain/DomainFieldRow.java @@ -1090,8 +1090,7 @@ public String getValueExpression() */ public CalculatedColumnAssistantDialog openAIAssistant() { - if (!isExpanded()) - expand(); + expand(); elementCache().aiAssistantButton.click(); return new CalculatedColumnAssistantDialog(this); } @@ -1102,8 +1101,7 @@ public CalculatedColumnAssistantDialog openAIAssistant() */ public boolean hasAIAssistantButton() { - if (!isExpanded()) - expand(); + expand(); return Locator.tagWithClass("button", "btn") .withText("AI Assistant") .findElementOrNull(this) != null; From 2db1394bcb220260ce522d32d8b95cb65a4b98a5 Mon Sep 17 00:00:00 2001 From: Daria Bodiakova <70635654+DariaBod@users.noreply.github.com> Date: Tue, 2 Jun 2026 13:46:05 -0700 Subject: [PATCH 5/5] fix comments --- .../test/components/domain/CalculatedColumnAssistantDialog.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/org/labkey/test/components/domain/CalculatedColumnAssistantDialog.java b/src/org/labkey/test/components/domain/CalculatedColumnAssistantDialog.java index fb4a495fb3..cc8a6c1ab6 100644 --- a/src/org/labkey/test/components/domain/CalculatedColumnAssistantDialog.java +++ b/src/org/labkey/test/components/domain/CalculatedColumnAssistantDialog.java @@ -240,6 +240,6 @@ protected class ElementCache extends ModalDialog.ElementCache final WebElement promptInput = Locator.tagWithClass("textarea", "prompt-input").findWhenNeeded(this); - final WebElement promptSubmitButton = Locator.tagWithClass("button", "prompt-button").findWhenNeeded(this); + final WebElement promptSubmitButton = Locator.tagWithClass("button", "prompt-button").refindWhenNeeded(this); } }