diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..cb616581 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: maven + directory: app + schedule: + interval: daily + target-branch: dev + + - package-ecosystem: github-actions + directory: / + schedule: + interval: daily + target-branch: dev \ No newline at end of file diff --git a/.github/workflows/simple-build-test.yml b/.github/workflows/simple-build-test.yml new file mode 100644 index 00000000..4bde0ed8 --- /dev/null +++ b/.github/workflows/simple-build-test.yml @@ -0,0 +1,55 @@ +name: Simple build and test + +on: + push: + branches: + - dev + paths-ignore: + - '**/*.md' + - '.github/dependabot.yml' + - '.github/workflows/dependabot-automerge.yml' + pull_request: + branches: + - dev + paths-ignore: + - '**/*.md' + - '.github/dependabot.yml' + - '.github/workflows/dependabot-automerge.yml' + workflow_dispatch: + +concurrency: + group: "workflow = ${{ github.workflow }}, ref = ${{ github.event.ref }}, pr = ${{ github.event.pull_request.id }}" + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + jvm-build-test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + java: + - '17' + - '21' + services: + ollama: + image: ollama/ollama + ports: + - 11434:11434 + name: "jvm-build-test-${{ matrix.java }}" + steps: + - uses: actions/checkout@v4 + + - name: Setup Java ${{ matrix.java }} + uses: actions/setup-java@v4 + with: + java-version: ${{ matrix.java }} + distribution: temurin + cache: maven + + - name: "build-test-jvm-java${{ matrix.java }}" + working-directory: app + run: | + ./mvnw -B clean verify \ + -Dquarkus.langchain4j.chat-model.provider=ollama \ + -Dquarkus.http.host=0.0.0.0 \ + -Dmaven.compiler.release=${{ matrix.java }} \ No newline at end of file diff --git a/app/pom.xml b/app/pom.xml index 8ccf8275..1989c556 100644 --- a/app/pom.xml +++ b/app/pom.xml @@ -6,11 +6,14 @@ insurance-app 1.0.0-SNAPSHOT + 3.26.0 3.12.1 17 UTF-8 UTF-8 0.15.1 + 1.0.0 + 2.3.7 quarkus-bom io.quarkus.platform 3.11.0 @@ -67,18 +70,51 @@ io.quarkiverse.quinoa quarkus-quinoa - 2.3.7 + ${quarkus.quinoa.version} io.quarkus quarkus-junit5 test + + io.quarkus + quarkus-junit5-mockito + test + + + io.quarkus + quarkus-panache-mock + test + io.rest-assured rest-assured test + + io.quarkiverse.quinoa + quarkus-quinoa-testing + ${quarkus.quinoa.version} + test + + + io.quarkiverse.playwright + quarkus-playwright + ${quarkus.playwright.version} + test + + + org.assertj + assertj-core + ${assertj.version} + test + + + org.awaitility + awaitility + test + @@ -114,6 +150,9 @@ org.jboss.logmanager.LogManager ${maven.home} + + http://localhost:8081/api + @@ -133,6 +172,9 @@ org.jboss.logmanager.LogManager ${maven.home} + + http://localhost:8081/api + diff --git a/app/src/main/java/org/parasol/ai/ClaimService.java b/app/src/main/java/org/parasol/ai/ClaimService.java index 1c96a119..05d7a630 100644 --- a/app/src/main/java/org/parasol/ai/ClaimService.java +++ b/app/src/main/java/org/parasol/ai/ClaimService.java @@ -1,13 +1,13 @@ package org.parasol.ai; +import jakarta.enterprise.context.SessionScoped; + import org.parasol.model.ClaimBotQuery; -import org.parasol.model.ClaimBotQueryResponse; import dev.langchain4j.service.SystemMessage; import dev.langchain4j.service.UserMessage; import io.quarkiverse.langchain4j.RegisterAiService; import io.smallrye.mutiny.Multi; -import jakarta.enterprise.context.SessionScoped; @RegisterAiService @SessionScoped @@ -22,9 +22,9 @@ public interface ClaimService { ) @UserMessage(""" Claim Summary: - {{claim}} + {{query.claim}} - Question: {{query}} + Question: {{query.query}} """) - Multi chat(String claim, String query); + Multi chat(ClaimBotQuery query); } diff --git a/app/src/main/java/org/parasol/model/Claim.java b/app/src/main/java/org/parasol/model/Claim.java index 460a3d4b..9e839de4 100644 --- a/app/src/main/java/org/parasol/model/Claim.java +++ b/app/src/main/java/org/parasol/model/Claim.java @@ -1,16 +1,20 @@ package org.parasol.model; -import io.quarkus.hibernate.orm.panache.PanacheEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import io.quarkus.hibernate.orm.panache.PanacheEntity; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + @Entity +@JsonNaming(SnakeCaseStrategy.class) public class Claim extends PanacheEntity { - - public String claim_number; + public String claimNumber; public String category; - public String policy_number; - public String client_name; + public String policyNumber; + public String clientName; public String subject; @Column(length = 5000) public String body; diff --git a/app/src/main/java/org/parasol/model/ClaimBotQuery.java b/app/src/main/java/org/parasol/model/ClaimBotQuery.java index 5dd020fd..d342f661 100644 --- a/app/src/main/java/org/parasol/model/ClaimBotQuery.java +++ b/app/src/main/java/org/parasol/model/ClaimBotQuery.java @@ -1,11 +1,5 @@ package org.parasol.model; -import com.fasterxml.jackson.annotation.JsonCreator; - public record ClaimBotQuery(String claim, String query) { - @JsonCreator - public ClaimBotQuery { - } - } diff --git a/app/src/main/java/org/parasol/model/ClaimBotQueryResponse.java b/app/src/main/java/org/parasol/model/ClaimBotQueryResponse.java index 8a8a278a..fc192b33 100644 --- a/app/src/main/java/org/parasol/model/ClaimBotQueryResponse.java +++ b/app/src/main/java/org/parasol/model/ClaimBotQueryResponse.java @@ -1,11 +1,5 @@ package org.parasol.model; -import com.fasterxml.jackson.annotation.JsonCreator; - public record ClaimBotQueryResponse(String type, String token, String source) { - @JsonCreator - public ClaimBotQueryResponse { - } - } diff --git a/app/src/main/java/org/parasol/resources/ClaimWebsocketChatBot.java b/app/src/main/java/org/parasol/resources/ClaimWebsocketChatBot.java index 559cb8e4..b54bb3c0 100644 --- a/app/src/main/java/org/parasol/resources/ClaimWebsocketChatBot.java +++ b/app/src/main/java/org/parasol/resources/ClaimWebsocketChatBot.java @@ -4,26 +4,37 @@ import org.parasol.model.ClaimBotQuery; import org.parasol.model.ClaimBotQueryResponse; +import io.quarkus.logging.Log; +import io.quarkus.websockets.next.OnClose; import io.quarkus.websockets.next.OnOpen; import io.quarkus.websockets.next.OnTextMessage; import io.quarkus.websockets.next.WebSocket; +import io.quarkus.websockets.next.WebSocketConnection; + import io.smallrye.mutiny.Multi; @WebSocket(path = "/ws/query") public class ClaimWebsocketChatBot { - private final ClaimService bot; + private final ClaimService bot; public ClaimWebsocketChatBot(ClaimService bot) { this.bot = bot; } @OnOpen - public void onOpen() { - System.out.println("Websocket opened"); + public void onOpen(WebSocketConnection connection) { + Log.infof("Websocket connection %s opened", connection.id()); + } + + @OnClose + public void onClose(WebSocketConnection connection) { + Log.infof("Websocket connection %s closed", connection.id()); } + @OnTextMessage public Multi onMessage(ClaimBotQuery query) { - return bot.chat(query.claim(), query.query()) + return bot.chat(query) + .invoke(response -> Log.debugf("Got chat response: %s", response)) .map(resp -> new ClaimBotQueryResponse("token", resp, "")); } } diff --git a/app/src/main/java/org/parasol/resources/GreetingResource.java b/app/src/main/java/org/parasol/resources/GreetingResource.java deleted file mode 100644 index 992cbcd4..00000000 --- a/app/src/main/java/org/parasol/resources/GreetingResource.java +++ /dev/null @@ -1,16 +0,0 @@ -package org.parasol.resources; - -import jakarta.ws.rs.GET; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.core.MediaType; - -@Path("/hello") -public class GreetingResource { - - @GET - @Produces(MediaType.TEXT_PLAIN) - public String hello() { - return "Hello from Quarkus REST"; - } -} diff --git a/app/src/main/resources/application.properties b/app/src/main/resources/application.properties index 63da5042..616c68ca 100644 --- a/app/src/main/resources/application.properties +++ b/app/src/main/resources/application.properties @@ -14,18 +14,23 @@ quarkus.langchain4j.openai.base-url=http://localhost:8000/v1 # Ollama quarkus.langchain4j.ollama.timeout=600s -# quarkus.langchain4j.ollama.base-url=http://127.0.0.1:11434 quarkus.langchain4j.ollama.model-id=llama3 quarkus.langchain4j.ollama.chat-model.temperature=0.3 quarkus.http.host=0.0.0.0 quarkus.http.port=8005 quarkus.http.cors=true -quarkus.dev-ui.cors.enabled=false quarkus.http.cors.origins=* +quarkus.dev-ui.cors.enabled=false +#%dev.quarkus.http.cors.origins=/.*/ + +quarkus.hibernate-orm.physical-naming-strategy=org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy +quarkus.log.category."org.parasol".level=DEBUG +%dev,test.quarkus.log.console.level=DEBUG # Quinoa quarkus.quinoa.package-manager-install=true quarkus.quinoa.package-manager-install.node-version=22.2.0 +quarkus.quinoa.package-manager-install.npm-version=10.8.1 quarkus.quinoa.build-dir=dist quarkus.quinoa.enable-spa-routing=true diff --git a/app/src/test/java/org/parasol/resources/ClaimResourceTests.java b/app/src/test/java/org/parasol/resources/ClaimResourceTests.java new file mode 100644 index 00000000..005820a7 --- /dev/null +++ b/app/src/test/java/org/parasol/resources/ClaimResourceTests.java @@ -0,0 +1,110 @@ +package org.parasol.resources; + +import static io.restassured.RestAssured.get; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.*; +import static org.mockito.Mockito.when; + +import java.util.List; + +import jakarta.ws.rs.core.Response.Status; + +import org.junit.jupiter.api.Test; +import org.parasol.model.Claim; + +import io.quarkus.panache.mock.PanacheMock; +import io.quarkus.test.junit.QuarkusTest; + +import io.restassured.http.ContentType; + +@QuarkusTest +class ClaimResourceTests { + @Test + void getAllNoneFound() { + PanacheMock.mock(Claim.class); + + when(Claim.listAll()) + .thenReturn(List.of()); + + get("/api/db/claims").then() + .statusCode(Status.OK.getStatusCode()) + .contentType(ContentType.JSON) + .body("$.size()", is(0)); + + PanacheMock.verify(Claim.class).listAll(); + PanacheMock.verifyNoMoreInteractions(Claim.class); + } + + @Test + void getAllSomeFound() { + PanacheMock.mock(Claim.class); + when(Claim.listAll()) + .thenReturn(List.of(createClaim())); + + var claims = get("/api/db/claims").then() + .statusCode(Status.OK.getStatusCode()) + .contentType(ContentType.JSON) + .extract().body() + .jsonPath().getList(".", Claim.class); + + assertThat(claims) + .isNotNull() + .singleElement() + .usingRecursiveComparison() + .isEqualTo(createClaim()); + + PanacheMock.verify(Claim.class).listAll(); + PanacheMock.verifyNoMoreInteractions(Claim.class); + } + + @Test + void getOneNotFound() { + PanacheMock.mock(Claim.class); + when(Claim.findById(1)) + .thenReturn(null); + + get("/api/db/claims/{id}", 1).then() + .statusCode(Status.NO_CONTENT.getStatusCode()) + .contentType(ContentType.JSON) + .body(blankOrNullString()); + + PanacheMock.verify(Claim.class).findById(1); + PanacheMock.verifyNoMoreInteractions(Claim.class); + } + + @Test + void getOneFound() { + PanacheMock.mock(Claim.class); + when(Claim.findById(1)) + .thenReturn(createClaim()); + + var claim = get("/api/db/claims/{id}", 1).then() + .statusCode(Status.OK.getStatusCode()) + .contentType(ContentType.JSON) + .extract().as(Claim.class); + + assertThat(claim) + .isNotNull() + .usingRecursiveComparison() + .isEqualTo(createClaim()); + + PanacheMock.verify(Claim.class).findById(1); + PanacheMock.verifyNoMoreInteractions(Claim.class); + } + + private static Claim createClaim() { + var claim = new Claim(); + claim.claimNumber = "001"; + claim.category = "Auto"; + claim.policyNumber = "123"; + claim.clientName = "client"; + claim.subject = "collision"; + claim.body = "body"; + claim.summary = "Car was damaged in accident"; + claim.location = "driveway"; + claim.time = "afternoon"; + claim.sentiment = "Very bad"; + + return claim; + } +} \ No newline at end of file diff --git a/app/src/test/java/org/parasol/resources/ClaimWebsocketChatBotTests.java b/app/src/test/java/org/parasol/resources/ClaimWebsocketChatBotTests.java new file mode 100644 index 00000000..2052791e --- /dev/null +++ b/app/src/test/java/org/parasol/resources/ClaimWebsocketChatBotTests.java @@ -0,0 +1,134 @@ +package org.parasol.resources; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.mockito.Mockito.*; + +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingDeque; + +import jakarta.inject.Inject; + +import org.jboss.logging.Logger; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatcher; +import org.parasol.ai.ClaimService; +import org.parasol.model.ClaimBotQuery; +import org.parasol.model.ClaimBotQueryResponse; + +import io.quarkus.test.InjectMock; +import io.quarkus.test.common.http.TestHTTPResource; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.websockets.next.CloseReason; +import io.quarkus.websockets.next.OnClose; +import io.quarkus.websockets.next.OnError; +import io.quarkus.websockets.next.OnOpen; +import io.quarkus.websockets.next.OnTextMessage; +import io.quarkus.websockets.next.WebSocketClient; +import io.quarkus.websockets.next.WebSocketClientConnection; +import io.quarkus.websockets.next.WebSocketConnector; + +import io.smallrye.mutiny.Multi; +import io.smallrye.mutiny.Uni; + +@QuarkusTest +class ClaimWebsocketChatBotTests { + private static final String CLAIM = "This is the claim details"; + private static final String QUERY = "Should I approve this claim?"; + private static final List RESPONSE = List.of("You", "should", "not", "approve", "this", "claim"); + + @InjectMock + ClaimService claimService; + + @TestHTTPResource("/") + URI claimChatBotRootUri; + + @Inject + WebSocketConnector connector; + + @Test + void chatBotWorks() { + ArgumentMatcher matcher = query -> + Objects.nonNull(query) && + QUERY.equals(query.query()) && + CLAIM.equals(query.claim()); + + // A Multi which will return our response with a 0.5 second delay between each item + var delayedMulti = Multi.createFrom().iterable(RESPONSE) + .onItem().call(() -> Uni.createFrom().nullItem().onItem().delayIt().by(Duration.ofMillis(500))); + + // Set up our AI mock + when(this.claimService.chat(argThat(matcher))) + .thenReturn(delayedMulti); + + // Create a WebSocket connection and wait for the connection to establish + var connection = connectClient(); + + // Send our query + connection.sendTextAndAwait(new ClaimBotQuery(CLAIM, QUERY)); + + // Wait for the server to respond with what we expect + await() + .atMost(Duration.ofMinutes(5)) + .until(() -> ClientEndpoint.MESSAGES.size() == RESPONSE.size()); + + // Verify the messages are what we expected + assertThat(ClientEndpoint.MESSAGES) + .hasSameElementsAs(RESPONSE); + + // Close the connection + connection.closeAndAwait(); + + // Verify the AI chat was called with the correct parameters + verify(this.claimService).chat(argThat(matcher)); + verifyNoMoreInteractions(this.claimService); + } + + private WebSocketClientConnection connectClient() { + var connection = this.connector + .baseUri(this.claimChatBotRootUri) + .connectAndAwait(); + + waitForClientToStart(); + + return connection; + } + + private static void waitForClientToStart() { + await() + .atMost(Duration.ofMinutes(5)) + .until(() -> "CONNECT".equals(ClientEndpoint.MESSAGES.poll())); + } + + @WebSocketClient(path = "/ws/query", clientId = "c1") + static class ClientEndpoint { + private final Logger logger = Logger.getLogger(ClientEndpoint.class); + static final BlockingQueue MESSAGES = new LinkedBlockingDeque<>(); + + @OnOpen + void open(WebSocketClientConnection connection) { + this.logger.infof("[CLIENT] Opening endpoint %s", connection.id()); + MESSAGES.offer("CONNECT"); + } + + @OnTextMessage + void textMessage(ClaimBotQueryResponse message) { + this.logger.infof("[CLIENT] Got message: %s", message.token()); + MESSAGES.offer(message.token()); + } + + @OnError + void error(Throwable error) { + this.logger.errorf(error, "[CLIENT] Encountered an error"); + } + + @OnClose + void close(CloseReason closeReason, WebSocketClientConnection connection) { + this.logger.infof("[CLIENT] Closing endpoint %s: %s: %s", connection.id(), closeReason.getCode(), closeReason.getMessage()); + } + } +} \ No newline at end of file diff --git a/app/src/test/java/org/parasol/ui/ClaimsDetailPageTests.java b/app/src/test/java/org/parasol/ui/ClaimsDetailPageTests.java new file mode 100644 index 00000000..ab88c127 --- /dev/null +++ b/app/src/test/java/org/parasol/ui/ClaimsDetailPageTests.java @@ -0,0 +1,109 @@ +package org.parasol.ui; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.time.Duration; +import java.util.Optional; + +import jakarta.ws.rs.core.Response.Status; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.junit.jupiter.api.Test; +import org.parasol.model.Claim; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; + +import com.microsoft.playwright.BrowserContext; +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Response; +import com.microsoft.playwright.assertions.PlaywrightAssertions; +import com.microsoft.playwright.options.LoadState; +import io.quarkiverse.playwright.InjectPlaywright; +import io.quarkiverse.playwright.WithPlaywright; +import io.quarkiverse.quinoa.testing.QuinoaTestProfiles; + +@QuarkusTest +@TestProfile(QuinoaTestProfiles.Enable.class) +@WithPlaywright +public class ClaimsDetailPageTests { + @InjectPlaywright + BrowserContext context; + + @ConfigProperty(name = "quarkus.http.test-port") + int quarkusPort; + + @Test + void pageLoads() { + var claim = Claim.findAll().firstResultOptional().orElseThrow(() -> new IllegalArgumentException("Can not find a claim in the database to use for tests")); + var page = loadPage(claim); + + PlaywrightAssertions.assertThat(page) + .hasTitle("Claim Detail"); + + PlaywrightAssertions.assertThat(page.getByText(claim.claimNumber)) + .isVisible(); + + PlaywrightAssertions.assertThat(page.getByText(claim.summary)) + .isVisible(); + + PlaywrightAssertions.assertThat(page.getByText(claim.sentiment)) + .isVisible(); + + var openChatButton = page.getByLabel("OpenChat"); + PlaywrightAssertions.assertThat(openChatButton) + .isVisible(); + + openChatButton.click(); + + PlaywrightAssertions.assertThat(page.getByText("Hi! I am Parasol Assistant. How can I help you today?")) + .isVisible(); + + assertThat(page.locator(".chat-answer-text").count()) + .isOne(); + + var askMeAnythingField = page.getByPlaceholder("Ask me anything..."); + PlaywrightAssertions.assertThat(askMeAnythingField) + .isVisible(); + askMeAnythingField.fill("Should I approve this claim?"); + + var sendQueryButton = page.getByLabel("SendQuery"); + PlaywrightAssertions.assertThat(sendQueryButton) + .isVisible(); + sendQueryButton.click(); + + // Wait for the answer text to have at least one piece of text in the answer + await() + .atMost(Duration.ofSeconds(30)) + .until(() -> getChatResponseText(page).isPresent()); + + assertThat(getChatResponseText(page)) + .isNotNull() + .isPresent(); + } + + private Optional getChatResponseText(Page page) { + return page.locator(".chat-answer-text").all().stream() + .map(Locator::textContent) + .map(String::trim) + .filter(answer -> !"Hi! I am Parasol Assistant. How can I help you today?".equals(answer)) + .findFirst() + .filter(s -> !s.isEmpty()); + } + + private Page loadPage(Claim claim) { + var page = this.context.newPage(); + var response = page.navigate("http://localhost:%d/ClaimDetail/%d".formatted(this.quarkusPort, claim.id)); + + assertThat(response) + .isNotNull() + .extracting(Response::status) + .isEqualTo(Status.OK.getStatusCode()); + + page.waitForLoadState(LoadState.NETWORKIDLE); + + return page; + } +} diff --git a/app/src/test/java/org/parasol/ui/ClaimsListPageTests.java b/app/src/test/java/org/parasol/ui/ClaimsListPageTests.java new file mode 100644 index 00000000..ec292992 --- /dev/null +++ b/app/src/test/java/org/parasol/ui/ClaimsListPageTests.java @@ -0,0 +1,145 @@ +package org.parasol.ui; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import jakarta.ws.rs.core.Response.Status; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.junit.jupiter.api.Test; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; + +import com.microsoft.playwright.BrowserContext; +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Response; +import com.microsoft.playwright.assertions.PlaywrightAssertions; +import com.microsoft.playwright.options.AriaRole; +import com.microsoft.playwright.options.LoadState; +import io.quarkiverse.playwright.InjectPlaywright; +import io.quarkiverse.playwright.WithPlaywright; +import io.quarkiverse.quinoa.testing.QuinoaTestProfiles; + +@QuarkusTest +@TestProfile(QuinoaTestProfiles.Enable.class) +@WithPlaywright +public class ClaimsListPageTests { + private static final int NB_CLAIMS = 6; + + @InjectPlaywright + BrowserContext context; + + @ConfigProperty(name = "quarkus.http.test-port") + int quarkusPort; + + @Test + void pageLoads() { + var page = loadPage(); + + PlaywrightAssertions.assertThat(page) + .hasTitle("Claims List"); + } + + @Test + void correctTable() { + var table = getAndVerifyClaimsTable(NB_CLAIMS); + var tableColumns = table.getByRole(AriaRole.COLUMNHEADER).all(); + + assertThat(tableColumns) + .isNotNull() + .hasSize(5) + .extracting(Locator::textContent) + .containsExactly( +"Claim Number", + "Category", + "Client Name", + "Policy Number", + "Status" + ); + + var rows = getTableBodyRows(table); + assertThat(rows) + .isNotNull() + .hasSize(NB_CLAIMS); + + var firstRow = rows.get(0).getByRole(AriaRole.GRIDCELL).all(); + assertThat(firstRow) + .isNotNull() + .hasSize(5); + + assertThat(firstRow.get(0)) + .isNotNull() + .extracting( + Locator::textContent, + l -> l.getByRole(AriaRole.LINK).getAttribute("href") + ) + .containsExactly( + "CLM195501", + "/ClaimDetail/1".formatted(this.quarkusPort) + ); + + assertThat(firstRow.get(1)) + .isNotNull() + .extracting(Locator::textContent) + .isEqualTo("Multiple vehicle"); + + assertThat(firstRow.get(2)) + .isNotNull() + .extracting(Locator::textContent) + .isEqualTo("Marty McFly"); + + assertThat(firstRow.get(3)) + .isNotNull() + .extracting(Locator::textContent) + .isEqualTo("AC-987654321"); + + assertThat(firstRow.get(4)) + .isNotNull() + .extracting(Locator::textContent) + .isEqualTo("Processed"); + } + + private List getTableBodyRows(Locator table) { + // Rowgroup 1 is the header row + // Rowgroup 2 is the body rows + var rowGroups = table.getByRole(AriaRole.ROWGROUP).all(); + assertThat(rowGroups) + .isNotNull() + .hasSize(2); + + return rowGroups.get(1).getByRole(AriaRole.ROW).all(); + } + + private Locator getAndVerifyClaimsTable(Page page, int expectedNumRows) { + var table = page.getByRole(AriaRole.GRID); + assertThat(table).isNotNull(); + + var tableBodyRows = getTableBodyRows(table); + assertThat(tableBodyRows) + .isNotNull() + .hasSize(expectedNumRows); + + return table; + } + + private Locator getAndVerifyClaimsTable(int expectedNumRows) { + return getAndVerifyClaimsTable(loadPage(), expectedNumRows); + } + + private Page loadPage() { + var page = this.context.newPage(); + var response = page.navigate("http://localhost:%d/ClaimsList".formatted(this.quarkusPort)); + + assertThat(response) + .isNotNull() + .extracting(Response::status) + .isEqualTo(Status.OK.getStatusCode()); + + page.waitForLoadState(LoadState.NETWORKIDLE); + + return page; + } +}