From 7b0e448157534e40344378952462bcdcb00dc831 Mon Sep 17 00:00:00 2001 From: afprtclr Date: Tue, 29 Sep 2026 19:44:25 +0200 Subject: [PATCH 1/4] Automated tests for listing failed messages --- .../test/preconditions/failedMessages.ts | 132 ++++++ src/Frontend/test/preconditions/index.ts | 1 + .../actions/failedMessageSelection.ts | 26 ++ .../failedmessages/actions/requestRetry.ts | 20 + .../actions/sortFailedMessages.ts | 35 ++ .../listing-failed-message.spec.ts | 383 +++++++++++++----- .../questions/failedMessageRows.ts | 73 ++++ .../questions/failedMessagesView.ts | 102 +++++ 8 files changed, 672 insertions(+), 100 deletions(-) create mode 100644 src/Frontend/test/preconditions/failedMessages.ts create mode 100644 src/Frontend/test/specs/failedmessages/actions/failedMessageSelection.ts create mode 100644 src/Frontend/test/specs/failedmessages/actions/requestRetry.ts create mode 100644 src/Frontend/test/specs/failedmessages/actions/sortFailedMessages.ts create mode 100644 src/Frontend/test/specs/failedmessages/questions/failedMessageRows.ts create mode 100644 src/Frontend/test/specs/failedmessages/questions/failedMessagesView.ts diff --git a/src/Frontend/test/preconditions/failedMessages.ts b/src/Frontend/test/preconditions/failedMessages.ts new file mode 100644 index 0000000000..6337760911 --- /dev/null +++ b/src/Frontend/test/preconditions/failedMessages.ts @@ -0,0 +1,132 @@ +import dayjs from "@/utils/dayjs"; +import { FailedMessageStatus, type FailedMessage } from "@/resources/FailedMessage"; +import type GroupOperation from "@/resources/GroupOperation"; +import type { SetupFactoryOptions } from "../driver"; + +const RETRY_SETTLE_DELAY = 1500; + +export interface FailedMessageOptions { + messageType?: string; + endpoint?: string; + machine?: string; + failedHoursAgo?: number; + retryFailures?: number; + exceptionMessage?: string; +} + +export const createFailedMessage = ( + id: string, + { messageType = `Sales.OrderFailed.${id}`, endpoint = "Sales.Service", machine = "MACHINE-B", failedHoursAgo = 2, retryFailures = 0, exceptionMessage = `Order ${id} could not be processed` }: FailedMessageOptions = {} +): FailedMessage => { + const timeOfFailure = dayjs().subtract(failedHoursAgo, "hour"); + + return { + id, + message_type: messageType, + time_sent: timeOfFailure.subtract(1, "minute").toISOString(), + is_system_message: false, + exception: { + exception_type: "System.InvalidOperationException", + message: exceptionMessage, + source: "Sales.Service", + stack_trace: " at Sales.OrderHandler.Handle(OrderFailed message, IMessageHandlerContext context)", + }, + message_id: `message-${id}`, + number_of_processing_attempts: retryFailures + 1, + status: FailedMessageStatus.Unresolved, + sending_endpoint: { name: "Sales.Client", host_id: "host-sender", host: "MACHINE-A" }, + receiving_endpoint: { name: endpoint, host_id: "host-receiver", host: machine }, + queue_address: endpoint, + time_of_failure: timeOfFailure.toISOString(), + last_modified: timeOfFailure.toISOString(), + edited: false, + edit_of: "", + }; +}; + +export interface FailedMessageGroup { + id: string; + title: string; + messageIds: string[]; +} + +export interface FailedMessagesTestBed { + failedMessages: FailedMessage[]; + retriedIds: string[]; +} + +export const hasFailedMessages = + ({ messages = [], groups = [] }: { messages?: FailedMessage[]; groups?: FailedMessageGroup[] } = {}) => + ({ driver }: SetupFactoryOptions): FailedMessagesTestBed => { + const serviceControlUrl = window.defaultConfig.service_control_url; + const table = [...messages]; + const membership = new Map(groups.map((group) => [group.id, new Set(group.messageIds)])); + const titles = new Map(groups.map((group) => [group.id, group.title])); + const retriedIds: string[] = []; + + const groupOperations = (): GroupOperation[] => + groups.map((group) => ({ + id: group.id, + title: group.title, + type: "Endpoint Name", + count: membership.get(group.id)?.size ?? 0, + comment: "", + operation_status: "none", + operation_progress: 0, + need_user_acknowledgement: false, + })); + + const respondWith = (rows: FailedMessage[]) => ({ body: rows, headers: { "Total-Count": String(rows.length) } }); + + const unresolvedRows = () => table.filter((message) => message.status === FailedMessageStatus.Unresolved); + + const sortRows = (rows: FailedMessage[], url: URL) => { + const sort = url.searchParams.get("sort"); + if (sort !== "time_of_failure" && sort !== "message_type") { + return rows; + } + const direction = url.searchParams.get("direction") === "asc" ? 1 : -1; + return [...rows].sort((a, b) => (sort === "time_of_failure" ? a.time_of_failure.localeCompare(b.time_of_failure) : (a.message_type ?? "").localeCompare(b.message_type ?? "")) * direction); + }; + + driver.mockEndpointDynamic(`${serviceControlUrl}errors`, "get", (url) => { + if (url.searchParams.get("status") !== FailedMessageStatus.Unresolved) { + return Promise.resolve(respondWith([])); + } + return Promise.resolve(respondWith(sortRows(unresolvedRows(), url))); + }); + + driver.mockEndpointDynamic(`${serviceControlUrl}recoverability/groups/:groupId/errors`, "get", (url, params) => { + const memberIds = membership.get(String(params.groupId)); + if (!memberIds) { + return Promise.resolve(respondWith([])); + } + const rows = unresolvedRows().filter((message) => memberIds.has(message.id)); + return Promise.resolve(respondWith(sortRows(rows, url))); + }); + + driver.mockEndpointDynamic(`${serviceControlUrl}recoverability/groups/id/:groupId`, "get", (_url, params) => { + const groupId = String(params.groupId); + const memberIds = membership.get(groupId); + return Promise.resolve({ body: { id: groupId, title: titles.get(groupId) ?? "Unknown group", type: "Endpoint Name", count: memberIds?.size ?? 0 } }); + }); + + driver.mockEndpointDynamic(`${serviceControlUrl}recoverability/groups/`, "get", () => Promise.resolve({ body: groupOperations() })); + + driver.mockEndpointDynamic(`${serviceControlUrl}errors/retry`, "post", async (_url, _params, request) => { + const ids = ((await request.json()) as string[]) ?? []; + ids.forEach((id) => retriedIds.push(id)); + setTimeout(() => { + ids.forEach((id) => { + const message = table.find((candidate) => candidate.id === id); + if (message) { + message.status = FailedMessageStatus.RetryIssued; + } + membership.forEach((memberIds) => memberIds.delete(id)); + }); + }, RETRY_SETTLE_DELAY); + return { body: {} }; + }); + + return { failedMessages: table, retriedIds }; + }; diff --git a/src/Frontend/test/preconditions/index.ts b/src/Frontend/test/preconditions/index.ts index 5017ff7674..51dceb686c 100644 --- a/src/Frontend/test/preconditions/index.ts +++ b/src/Frontend/test/preconditions/index.ts @@ -16,6 +16,7 @@ export * from "../preconditions/hasHeartbeatEndpoints"; export { serviceControlWithMonitoring } from "./serviceControlWithMonitoring"; export * from "./recoverability"; export * from "./deletedMessages"; +export * from "./failedMessages"; export * from "./deletedMessageGroups"; export * from "./deletableFailedMessage"; export * from "./licensing"; diff --git a/src/Frontend/test/specs/failedmessages/actions/failedMessageSelection.ts b/src/Frontend/test/specs/failedmessages/actions/failedMessageSelection.ts new file mode 100644 index 0000000000..ff5772ba33 --- /dev/null +++ b/src/Frontend/test/specs/failedmessages/actions/failedMessageSelection.ts @@ -0,0 +1,26 @@ +import userEvent from "@testing-library/user-event"; +import { within } from "@testing-library/vue"; + +function toolbar(): HTMLElement { + const element = document.querySelector(".btn-toolbar"); + if (!element) { + throw new Error("The failed messages toolbar is not displayed"); + } + return element; +} + +export async function selectAllFailedMessages(): Promise { + await userEvent.click(within(toolbar()).getByRole("button", { name: /^select all$/i })); +} + +export async function clearFailedMessageSelection(): Promise { + await userEvent.click(within(toolbar()).getByRole("button", { name: /clear selection/i })); +} + +export async function selectFailedMessage(messageId: string): Promise { + const checkbox = document.querySelector(`#checkbox${messageId}`); + if (!checkbox) { + throw new Error(`Failed message ${messageId} is not displayed`); + } + await userEvent.click(checkbox); +} diff --git a/src/Frontend/test/specs/failedmessages/actions/requestRetry.ts b/src/Frontend/test/specs/failedmessages/actions/requestRetry.ts new file mode 100644 index 0000000000..8cd2fff5e9 --- /dev/null +++ b/src/Frontend/test/specs/failedmessages/actions/requestRetry.ts @@ -0,0 +1,20 @@ +import userEvent from "@testing-library/user-event"; +import { fireEvent } from "@testing-library/vue"; + +function retryButton(messageId: string): HTMLElement { + const row = document.querySelector(`.row.box.repeat-item.failed-message #checkbox${messageId}`)?.closest(".failed-message"); + const button = row?.querySelector('button[name="retryMessage"]'); + if (!button) { + throw new Error(`The 'Request retry' action for failed message ${messageId} is not displayed`); + } + return button; +} + +export async function requestRetryFor(messageId: string): Promise { + const button = retryButton(messageId); + try { + await userEvent.click(button); + } catch { + fireEvent.click(button); + } +} diff --git a/src/Frontend/test/specs/failedmessages/actions/sortFailedMessages.ts b/src/Frontend/test/specs/failedmessages/actions/sortFailedMessages.ts new file mode 100644 index 0000000000..d84bffb4b5 --- /dev/null +++ b/src/Frontend/test/specs/failedmessages/actions/sortFailedMessages.ts @@ -0,0 +1,35 @@ +import userEvent from "@testing-library/user-event"; +import { within } from "@testing-library/vue"; +import { normalise } from "../questions/domText"; + +function dropdownMenu(): HTMLElement { + const toggle = document.querySelector(".msg-group-menu .btn.dropdown-toggle"); + if (toggle) { + toggle.click(); //Bootstrap toggles the menu open; the option buttons live inside .dropdown-menu + } + const menu = document.querySelector(".msg-group-menu .dropdown-menu"); + if (!menu) { + throw new Error("The sort-by dropdown is not displayed"); + } + return menu; +} + +function optionButton(description: string, descending: boolean): HTMLElement { + const buttons = within(dropdownMenu()).getAllByRole("button"); + const match = buttons.find((button) => { + const text = normalise(button.textContent ?? ""); + return descending ? text === `${description} (Descending)` : text === description; + }); + if (!match) { + throw new Error(`The '${descending ? `${description} (Descending)` : description}' sort option is not displayed`); + } + return match; +} + +export async function sortBy(description: string): Promise { + await userEvent.click(optionButton(description, false)); +} + +export async function sortByDescending(description: string): Promise { + await userEvent.click(optionButton(description, true)); +} diff --git a/src/Frontend/test/specs/failedmessages/listing-failed-message.spec.ts b/src/Frontend/test/specs/failedmessages/listing-failed-message.spec.ts index 09ea3f6c01..2b8e502298 100644 --- a/src/Frontend/test/specs/failedmessages/listing-failed-message.spec.ts +++ b/src/Frontend/test/specs/failedmessages/listing-failed-message.spec.ts @@ -1,116 +1,299 @@ +import { expect } from "vitest"; +import { waitFor } from "@testing-library/vue"; import { test, describe } from "../../drivers/vitest/driver"; +import type { Driver } from "../../driver"; +import * as precondition from "../../preconditions"; +import type { FailedMessageGroup, FailedMessagesTestBed } from "../../preconditions/failedMessages"; +import type { FailedMessage } from "@/resources/FailedMessage"; +import routeLinks from "@/router/routeLinks"; +import { getFailedMessageRowCount, getFailedMessageRow, isFailedMessageListed, isRetryInProgress, listedMessageIds } from "./questions/failedMessageRows"; +import { + allFailedMessagesTabBadge, + browserTabTitle, + deleteSelectedText, + exportSelectedText, + failedMessagesNavBadge, + groupHeading, + groupMessageCount, + isEmptyMessageVisible, + isDeleteSelectedDisabled, + isExportSelectedDisabled, + isRetrySelectedDisabled, + isSelectionButtonDisabled, + isTabActive, + retrySelectedText, + selectionButtonText, +} from "./questions/failedMessagesView"; +import { clearFailedMessageSelection, selectAllFailedMessages, selectFailedMessage } from "./actions/failedMessageSelection"; +import { openTab } from "./actions/openTab"; +import { requestRetryFor } from "./actions/requestRetry"; +import { sortBy, sortByDescending } from "./actions/sortFailedMessages"; + +const ALL_FAILED_MESSAGES = routeLinks.failedMessage.failedMessages.link; +const FAILED_MESSAGE_GROUPS = routeLinks.failedMessage.failedMessagesGroups.link; +const GROUP_ONE = "group-1"; +const GROUP_TWO = "group-2"; +const GROUP_ONE_TITLE = "Payments Group"; + +function failedMessagesFixture() { + return { + oldest: precondition.createFailedMessage("msg-1", { messageType: "Sales.Third", failedHoursAgo: 8 }), + middle: precondition.createFailedMessage("msg-2", { messageType: "Sales.First", failedHoursAgo: 4, retryFailures: 2, exceptionMessage: "Payment gateway timed out" }), + newest: precondition.createFailedMessage("msg-3", { messageType: "Sales.Second", failedHoursAgo: 1 }), + }; +} + +function asList(fixture: Partial>): FailedMessage[] { + return Object.values(fixture).filter((message): message is FailedMessage => message !== undefined); +} + +async function givenFailedMessagesAreShown(driver: Driver, messages: FailedMessage[]): Promise { + await driver.setUp(precondition.serviceControlWithMonitoring); + const bed = await driver.setUp(precondition.hasFailedMessages({ messages })); + await driver.goTo(ALL_FAILED_MESSAGES); + await waitFor(() => expect(getFailedMessageRowCount()).toBe(messages.length), { timeout: 5000 }); + return bed; +} + +async function givenFailedMessagesInAPaymentsGroupAreShown(driver: Driver): Promise { + const fixture = failedMessagesFixture(); + const groups: FailedMessageGroup[] = [ + { id: GROUP_ONE, title: GROUP_ONE_TITLE, messageIds: [fixture.oldest.id, fixture.middle.id] }, + { id: GROUP_TWO, title: "Shipping Group", messageIds: [fixture.newest.id] }, + ]; + + await driver.setUp(precondition.serviceControlWithMonitoring); + const bed = await driver.setUp(precondition.hasFailedMessages({ messages: asList(fixture), groups })); + await driver.goTo(routeLinks.failedMessage.group.link(GROUP_ONE)); + await waitFor(() => expect(getFailedMessageRowCount()).toBe(groups[0].messageIds.length), { timeout: 5000 }); + return bed; +} describe("FEATURE: All Failed Messages", () => { describe("RULE: All failed messages view should show an unfiltered list", () => { - test.todo("EXAMPLE: All failed messages tab should be highlighted as active"); - test.todo("EXAMPLE: Browser tab title should show 'All Failed Messages'"); - test.todo("EXAMPLE: Failed messages should be ordered according to the selected sort by field"); - - /* SCENARIO - Given the entry route to the failed messages view is from the "All Failed Messages" tab - Then the view should show all current failed messages - and the "All Failed Messages" tab should be highlighted as active - and the browser tab title should show "All Failed Messages" - */ - - test.todo("EXAMPLE: Failed messages should be ordered according to the selected sort by field"); - /* SCENARIO - Given the failed messages are shown - Then they are ordered according to the selected Sort By field - */ - - test.todo("EXAMPLE: A failed message should display the current message name in bold"); - test.todo("EXAMPLE: A failed message should display a time period indicating how long ago the failure happened"); - test.todo("EXAMPLE: A failed message should display the name of the Endpoint that the message failed on"); - test.todo("EXAMPLE: A failed message should display the name of the Machine that the message failed on"); - test.todo("EXAMPLE: A failed message should display the exception message text"); - /* SCENARIO - Given there are 1 or more Failed Message rows shown - Then the row will display the current message name in bold - and the row will display a time period indicating how long ago the failure happened (retry failure if there is one) - and the row will display the name of the Endpoint that the message failed on - and the row will display the name of the Machine that the message failed on - and the row will display the exception message text - */ - - test.todo("EXAMPLE: A failed message should display the number of times it has failed retries"); - /* SCENARIO - Given there is a Failed Message row shown - and that row has previously been retried - Then the row will display the number of times it has failed retries (note: 1 less than total failures for the message) - and this retry failure information will be visually more prominent than the other information - */ - - test.todo("EXAMPLE: A message should be shown when there are no failed messages"); - /* SCENARIO - Given there are no Failed Messages - Then the "All Failed Message" tab will display a message indicating the fact - */ + test("EXAMPLE: All failed messages tab should be highlighted as active", async ({ driver }) => { + await givenFailedMessagesAreShown(driver, asList(failedMessagesFixture())); + + expect(isTabActive("All Failed Messages")).toBe(true); + }); + + test("EXAMPLE: Browser tab title should show 'All Failed Messages'", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasFailedMessages({ messages: asList(failedMessagesFixture()) })); + await driver.goTo(FAILED_MESSAGE_GROUPS); + + await openTab("All Failed Messages"); + + await waitFor(() => expect(browserTabTitle()).toBe("All Failed Messages • ServicePulse")); + }); + + test("EXAMPLE: Failed messages should be ordered according to the selected sort by field", async ({ driver }) => { + const { oldest, middle, newest } = failedMessagesFixture(); + await givenFailedMessagesAreShown(driver, asList({ oldest, middle, newest })); + + expect(listedMessageIds()).toEqual([newest.id, middle.id, oldest.id]); + }); + + test("EXAMPLE: Failed messages should be ordered according to the selected sort by field", async ({ driver }) => { + const { oldest, middle, newest } = failedMessagesFixture(); + await givenFailedMessagesAreShown(driver, asList({ oldest, middle, newest })); + + await sortBy("Message Type"); + await waitFor(() => expect(listedMessageIds()).toEqual([middle.id, newest.id, oldest.id]), { timeout: 5000 }); //Sales.First, Sales.Second, Sales.Third + + await sortByDescending("Message Type"); + await waitFor(() => expect(listedMessageIds()).toEqual([oldest.id, newest.id, middle.id]), { timeout: 5000 }); + }); + + test("EXAMPLE: A failed message should display the current message name in bold", async ({ driver }) => { + const { middle } = failedMessagesFixture(); + await givenFailedMessagesAreShown(driver, asList({ middle })); + + const row = getFailedMessageRow(middle.id); + expect(row?.messageType).toBe("Sales.First"); + expect(row?.messageTypeIsBold).toBe(true); + }); + + test("EXAMPLE: A failed message should display a time period indicating how long ago the failure happened", async ({ driver }) => { + const { middle } = failedMessagesFixture(); + await givenFailedMessagesAreShown(driver, asList({ middle })); + + expect(getFailedMessageRow(middle.id)?.failedSince).toBe("4 hours ago"); + }); + + test("EXAMPLE: A failed message should display the name of the Endpoint that the message failed on", async ({ driver }) => { + const { middle } = failedMessagesFixture(); + await givenFailedMessagesAreShown(driver, asList({ middle })); + + expect(getFailedMessageRow(middle.id)?.endpoint).toBe("Sales.Service"); + }); + + test("EXAMPLE: A failed message should display the name of the Machine that the message failed on", async ({ driver }) => { + const { middle } = failedMessagesFixture(); + await givenFailedMessagesAreShown(driver, asList({ middle })); + + expect(getFailedMessageRow(middle.id)?.machine).toBe("MACHINE-B"); + }); + + test("EXAMPLE: A failed message should display the exception message text", async ({ driver }) => { + const { middle } = failedMessagesFixture(); + await givenFailedMessagesAreShown(driver, asList({ middle })); + + expect(getFailedMessageRow(middle.id)?.exceptionMessage).toBe("Payment gateway timed out"); + }); + + test("EXAMPLE: A failed message should display the number of times it has failed retries", async ({ driver }) => { + const { middle } = failedMessagesFixture(); + await givenFailedMessagesAreShown(driver, asList({ middle })); + + const row = getFailedMessageRow(middle.id); + expect(row?.retryFailures).toBe("2 Retry Failures"); //1 less than the 3 processing attempts recorded for the message + expect(row?.retryFailuresAreEmphasised).toBe(true); + }); + + test("EXAMPLE: A message should be shown when there are no failed messages", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasFailedMessages({})); + await driver.goTo(ALL_FAILED_MESSAGES); + + await waitFor(() => expect(isEmptyMessageVisible()).toBe(true), { timeout: 5000 }); + }); }); + describe("RULE: Failed messages (group route) view should only show failed messages associated with that group", () => { - test.todo("EXAMPLE: Only messages of a selected group should be shown"); - test.todo("EXAMPLE: Group name should be shown as a heading"); - test.todo("EXAMPLE: Group message count should be shown as a subtext to the group heading"); - test.todo("EXAMPLE: Failed Message Groups tab should remain highlighted as active"); - /* SCENARIO - Given the entry route to the failed messages view is from selecting a group in the "Failed Message Groups" tab - Then the view should show only failed messages associated with the selected group - and the group name should be shown as a heading - and the group message count should be shown as a subtext to the group heading - and the "Failed Message Groups" tab should remain highlighted as active - */ + test("EXAMPLE: Only messages of a selected group should be shown", async ({ driver }) => { + await givenFailedMessagesInAPaymentsGroupAreShown(driver); + + expect(listedMessageIds().sort()).toEqual(["msg-1", "msg-2"]); + expect(isFailedMessageListed("msg-3")).toBe(false); + }); + + test("EXAMPLE: Group name should be shown as a heading", async ({ driver }) => { + await givenFailedMessagesInAPaymentsGroupAreShown(driver); + + expect(groupHeading()).toBe(GROUP_ONE_TITLE); + }); + + test("EXAMPLE: Group message count should be shown as a subtext to the group heading", async ({ driver }) => { + await givenFailedMessagesInAPaymentsGroupAreShown(driver); + + expect(groupMessageCount()).toBe("2 messages in group"); + }); + + test("EXAMPLE: Failed Message Groups tab should remain highlighted as active", async ({ driver }) => { + await givenFailedMessagesInAPaymentsGroupAreShown(driver); + + expect(isTabActive("Failed Message Groups")).toBe(true); + }); }); + describe("RULE: Row hover functionality", () => { - test.todo("EXAMPLE: Hovering the cursor over a failed message row should indicate that it is active, selectable, and show the 'Request Retry' action"); - - /* SCENARIO - Given there are 1 or more Failed Message rows shown - and the user hovers over a Failed Message row - Then the row indicates that it is active (hover state) - and that it is selectable (cursor/underlining) - and the "Request Retry" action is made available on the row - */ - - /* QUESTIONS - why is "Request Retry" not always shown, similar to "Request Retry" on the Failed Message Groups screen? - */ + test("EXAMPLE: Hovering the cursor over a failed message row should indicate that it is active, selectable, and show the 'Request Retry' action", async ({ driver }) => { + const { middle } = failedMessagesFixture(); + await givenFailedMessagesAreShown(driver, asList({ middle })); + + const row = getFailedMessageRow(middle.id); + expect(row?.isSelectable).toBe(true); + expect(row?.hasRequestRetryButton).toBe(true); + }); }); + describe('RULE: The badge counter on the "All Failed Messages" tab header and the "Failed messages" main navigation items should reflect the total count of failed messages', () => { - test.todo("Not implemented"); + test("EXAMPLE: The tab and navigation badges show the total number of failed messages", async ({ driver }) => { + await givenFailedMessagesAreShown(driver, asList(failedMessagesFixture())); + + await waitFor(() => expect(allFailedMessagesTabBadge()).toBe("3"), { timeout: 5000 }); + await waitFor(() => expect(failedMessagesNavBadge()).toBe("3"), { timeout: 5000 }); + }); }); + describe("RULE: action functionality", () => { - test.todo("EXAMPLE: Clicking the 'Request Retry' action should initiate a retry for the selected message"); - - /* SCENARIO - Given there are 1 or more Failed Message rows are shown - and the user clicks the "Request Retry" action for a row - Then the row indicates that it is pending a retry - and the row is removed from the "Failed Messages" list once the retry has been initiated - */ + test("EXAMPLE: Clicking the 'Request Retry' action should initiate a retry for the selected message", async ({ driver }) => { + const { middle } = failedMessagesFixture(); + const bed = await givenFailedMessagesAreShown(driver, asList({ middle })); + + await requestRetryFor(middle.id); + + await waitFor(() => expect(bed.retriedIds).toEqual([middle.id]), { timeout: 5000 }); + await waitFor(() => expect(isRetryInProgress(middle.id)).toBe(true), { timeout: 5000 }); + await waitFor(() => expect(isFailedMessageListed(middle.id)).toBe(false), { timeout: 10000 }); + }); }); + describe("RULE: button functionality", () => { - test.todo("EXAMPLE: When no Failed Message rows are selected, the 'Select All' button should be enabled"); - test.todo("EXAMPLE: When no Failed Message rows are selected, the 'Retry Selected' button should be disabled"); - test.todo("EXAMPLE: When no Failed Message rows are selected, the 'Delete Selected' button should be disabled"); - test.todo("EXAMPLE: When no Failed Message rows are selected, the 'Export Selected' button should be disabled"); - - /* SCENARIO - Given no Failed Message rows are selected - Then the "Select All" button is enabled - and the "Retry Selected" button is disabled - and the "Delete Selected" button is disabled - and the "Export Selected" button is disabled - */ - - test.todo("EXAMPLE: When 1 or more Failed Message rows are selected, the 'Select All' button should be replaced by a 'Clear Selection' button"); - test.todo("EXAMPLE: When 1 or more Failed Message rows are selected, the 'Retry Selected' button should indicate the number of rows selected and be enabled"); - test.todo("EXAMPLE: When 1 or more Failed Message rows are selected, the 'Delete Selected' button should indicate the number of rows selected and be enabled"); - /* SCENARIO - Given 1 or more Failed Message rows are selected - Then the "Select All" button is replaced by a "Clear Selection" button - and the "Retry selected" button indicates the number of rows selected and is enabled - and the "Delete selected" button indicates the number of rows selected and is enabled - and the "Export selected" button indicates the number of rows selected and is enabled - */ + test("EXAMPLE: When no Failed Message rows are selected, the 'Select All' button should be enabled", async ({ driver }) => { + await givenFailedMessagesAreShown(driver, asList(failedMessagesFixture())); + + expect(selectionButtonText()).toBe("Select all"); + expect(isSelectionButtonDisabled()).toBe(false); + }); + + test("EXAMPLE: When no Failed Message rows are selected, the 'Retry Selected' button should be disabled", async ({ driver }) => { + await givenFailedMessagesAreShown(driver, asList(failedMessagesFixture())); + + expect(retrySelectedText()).toBe("Retry 0 selected"); + expect(isRetrySelectedDisabled()).toBe(true); + }); + + test("EXAMPLE: When no Failed Message rows are selected, the 'Delete Selected' button should be disabled", async ({ driver }) => { + await givenFailedMessagesAreShown(driver, asList(failedMessagesFixture())); + + expect(deleteSelectedText()).toBe("Delete 0 selected"); + expect(isDeleteSelectedDisabled()).toBe(true); + }); + + test("EXAMPLE: When no Failed Message rows are selected, the 'Export Selected' button should be disabled", async ({ driver }) => { + await givenFailedMessagesAreShown(driver, asList(failedMessagesFixture())); + + expect(exportSelectedText()).toBe("Export 0 selected"); + expect(isExportSelectedDisabled()).toBe(true); + }); + + test("EXAMPLE: When 1 or more Failed Message rows are selected, the 'Select All' button should be replaced by a 'Clear Selection' button", async ({ driver }) => { + await givenFailedMessagesAreShown(driver, asList(failedMessagesFixture())); + + await selectFailedMessage("msg-2"); + + expect(selectionButtonText()).toBe("Clear selection"); + }); + + test("EXAMPLE: When 1 or more Failed Message rows are selected, the 'Retry Selected' button should indicate the number of rows selected and be enabled", async ({ driver }) => { + await givenFailedMessagesAreShown(driver, asList(failedMessagesFixture())); + + await selectAllFailedMessages(); + + expect(retrySelectedText()).toBe("Retry 3 selected"); + expect(isRetrySelectedDisabled()).toBe(false); + }); + + test("EXAMPLE: When 1 or more Failed Message rows are selected, the 'Delete Selected' button should indicate the number of rows selected and be enabled", async ({ driver }) => { + await givenFailedMessagesAreShown(driver, asList(failedMessagesFixture())); + + await selectAllFailedMessages(); + + expect(deleteSelectedText()).toBe("Delete 3 selected"); + expect(isDeleteSelectedDisabled()).toBe(false); + }); + + test("EXAMPLE: When 1 or more Failed Message rows are selected, the 'Export Selected' button should indicate the number of rows selected and be enabled", async ({ driver }) => { + await givenFailedMessagesAreShown(driver, asList(failedMessagesFixture())); + + await selectAllFailedMessages(); + + expect(exportSelectedText()).toBe("Export 3 selected"); + expect(isExportSelectedDisabled()).toBe(false); + }); + + test("EXAMPLE: Clearing the selection should restore the unselected button state", async ({ driver }) => { + await givenFailedMessagesAreShown(driver, asList(failedMessagesFixture())); + await selectAllFailedMessages(); + + await clearFailedMessageSelection(); + + expect(selectionButtonText()).toBe("Select all"); + expect(isRetrySelectedDisabled()).toBe(true); + expect(isDeleteSelectedDisabled()).toBe(true); + expect(isExportSelectedDisabled()).toBe(true); + }); }); }); diff --git a/src/Frontend/test/specs/failedmessages/questions/failedMessageRows.ts b/src/Frontend/test/specs/failedmessages/questions/failedMessageRows.ts new file mode 100644 index 0000000000..252d8dff6f --- /dev/null +++ b/src/Frontend/test/specs/failedmessages/questions/failedMessageRows.ts @@ -0,0 +1,73 @@ +import { isBold, labelledValue, normalise } from "./domText"; + +const ROW_SELECTOR = ".row.box.repeat-item.failed-message"; + +export interface FailedMessageRow { + id: string; + messageType: string; + messageTypeIsBold: boolean; + endpoint: string; + machine: string; + failedSince: string; + retryFailures: string | null; + retryFailuresAreEmphasised: boolean; + exceptionMessage: string; + isSelected: boolean; + isSelectable: boolean; + hasRequestRetryButton: boolean; + element: HTMLElement; +} + +function queryRows(): FailedMessageRow[] { + return Array.from(document.querySelectorAll(ROW_SELECTOR)).map((element) => { + const checkbox = element.querySelector("input.checkbox"); + const messageTypeElement = element.querySelector("p.lead.break"); + const spans = Array.from(element.querySelectorAll("p.metadata > span.metadata")).map((span) => normalise(span.textContent ?? "")); + const retryBadge = Array.from(element.querySelectorAll("p.metadata > span")).find((span) => /retry failures/i.test(span.textContent ?? "")); + + return { + id: checkbox?.id.replace(/^checkbox/, "") ?? "", + messageType: normalise(messageTypeElement?.textContent ?? ""), + messageTypeIsBold: isBold(messageTypeElement), + endpoint: labelledValue(spans, "Endpoint:"), + machine: labelledValue(spans, "Machine:"), + failedSince: labelledValue(spans, "Failed:"), + retryFailures: retryBadge ? normalise(retryBadge.textContent ?? "") : null, + retryFailuresAreEmphasised: retryBadge?.classList.contains("label-important") ?? false, + exceptionMessage: normalise(element.querySelector("pre.stacktrace-preview")?.textContent ?? ""), + isSelected: checkbox?.checked ?? false, + isSelectable: checkbox !== null && !checkbox.disabled, + hasRequestRetryButton: element.querySelector('button[name="retryMessage"]') !== null, + element, + }; + }); +} + +export function getFailedMessageRows(): FailedMessageRow[] { + return queryRows(); +} + +export function getFailedMessageRowCount(): number { + return queryRows().length; +} + +export function getFailedMessageRow(messageId: string): FailedMessageRow | undefined { + return queryRows().find((row) => row.id === messageId); +} + +export function isFailedMessageListed(messageId: string): boolean { + return getFailedMessageRow(messageId) !== undefined; +} + +export function getSelectedFailedMessageCount(): number { + return queryRows().filter((row) => row.isSelected).length; +} + +export function listedMessageIds(): string[] { + return queryRows().map((row) => row.id); +} + +export function isRetryInProgress(messageId: string): boolean { + const row = getFailedMessageRow(messageId); + return row ? /retry in progress/i.test(row.element.textContent ?? "") : false; +} diff --git a/src/Frontend/test/specs/failedmessages/questions/failedMessagesView.ts b/src/Frontend/test/specs/failedmessages/questions/failedMessagesView.ts new file mode 100644 index 0000000000..f9a5dee52b --- /dev/null +++ b/src/Frontend/test/specs/failedmessages/questions/failedMessagesView.ts @@ -0,0 +1,102 @@ +import { screen, within } from "@testing-library/vue"; +import { normalise } from "./domText"; + +function tabElements(): HTMLElement[] { + return Array.from(document.querySelectorAll(".tabs h5")); +} + +function tabLabel(tab: HTMLElement): string { + const link = tab.querySelector("a"); + if (!link) { + return ""; + } + const label = Array.from(link.childNodes) + .filter((node) => node.nodeType === Node.TEXT_NODE) + .map((node) => node.textContent ?? "") + .join(" "); + return normalise(label); +} + +export function isTabActive(tabName: string): boolean { + return tabElements().some((tab) => tabLabel(tab) === tabName && tab.classList.contains("active")); +} + +export function browserTabTitle(): string { + return document.title; +} + +export function allFailedMessagesTabBadge(): string | null { + const tab = tabElements().find((candidate) => tabLabel(candidate) === "All Failed Messages"); + const badge = tab?.querySelector("span.badge"); + return badge ? normalise(badge.textContent ?? "") : null; +} + +export function failedMessagesNavBadge(): string | null { + const navItem = Array.from(document.querySelectorAll("a")).find((anchor) => normalise(anchor.querySelector(".navbar-label")?.textContent ?? "") === "Failed Messages"); + const badge = navItem?.querySelector("span.badge"); + return badge ? normalise(badge.textContent ?? "") : null; +} + +export function groupHeading(): string | null { + const heading = document.querySelector("h1.group-title"); + return heading ? normalise(heading.textContent ?? "") : null; +} + +export function groupMessageCount(): string | null { + const count = document.querySelector("h3.group-message-count"); + return count ? normalise(count.textContent ?? "") : null; +} + +export function noMessagesMessage(): string | null { + const message = screen.queryByText(/there are currently no messages/i); + return message && message.getAttribute("role") === "status" ? normalise(message.textContent ?? "") : null; +} + +export function isEmptyMessageVisible(): boolean { + return noMessagesMessage() !== null; +} + +function toolbar(): HTMLElement { + const element = document.querySelector(".btn-toolbar"); + if (!element) { + throw new Error("The failed messages toolbar is not displayed"); + } + return element; +} + +function toolbarButton(name: RegExp): HTMLButtonElement { + return within(toolbar()).getByRole("button", { name }) as HTMLButtonElement; +} + +export function selectionButtonText(): string | null { + const button = within(toolbar()).queryByRole("button", { name: /^(select all|clear selection)$/i }); + return button ? normalise(button.textContent ?? "") : null; +} + +export function isSelectionButtonDisabled(): boolean { + return toolbarButton(/^(select all|clear selection)$/i).disabled; +} + +export function retrySelectedText(): string { + return normalise(toolbarButton(/retry \d+ selected/i).textContent ?? ""); +} + +export function isRetrySelectedDisabled(): boolean { + return toolbarButton(/retry \d+ selected/i).disabled; +} + +export function deleteSelectedText(): string { + return normalise(toolbarButton(/delete \d+ selected/i).textContent ?? ""); +} + +export function isDeleteSelectedDisabled(): boolean { + return toolbarButton(/delete \d+ selected/i).disabled; +} + +export function exportSelectedText(): string { + return normalise(toolbarButton(/export \d+ selected/i).textContent ?? ""); +} + +export function isExportSelectedDisabled(): boolean { + return toolbarButton(/export \d+ selected/i).disabled; +} From 2b3ca50a5fc062c4fc8a51838eaaa9836659d245 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 29 Sep 2026 19:55:30 +0200 Subject: [PATCH 2/4] Refactor requestRetry function and update test description Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../test/specs/failedmessages/actions/requestRetry.ts | 6 +----- .../specs/failedmessages/listing-failed-message.spec.ts | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/Frontend/test/specs/failedmessages/actions/requestRetry.ts b/src/Frontend/test/specs/failedmessages/actions/requestRetry.ts index 8cd2fff5e9..dff45821a0 100644 --- a/src/Frontend/test/specs/failedmessages/actions/requestRetry.ts +++ b/src/Frontend/test/specs/failedmessages/actions/requestRetry.ts @@ -12,9 +12,5 @@ function retryButton(messageId: string): HTMLElement { export async function requestRetryFor(messageId: string): Promise { const button = retryButton(messageId); - try { - await userEvent.click(button); - } catch { - fireEvent.click(button); - } + await userEvent.click(button); } diff --git a/src/Frontend/test/specs/failedmessages/listing-failed-message.spec.ts b/src/Frontend/test/specs/failedmessages/listing-failed-message.spec.ts index 2b8e502298..e7809709fa 100644 --- a/src/Frontend/test/specs/failedmessages/listing-failed-message.spec.ts +++ b/src/Frontend/test/specs/failedmessages/listing-failed-message.spec.ts @@ -87,7 +87,7 @@ describe("FEATURE: All Failed Messages", () => { await waitFor(() => expect(browserTabTitle()).toBe("All Failed Messages • ServicePulse")); }); - test("EXAMPLE: Failed messages should be ordered according to the selected sort by field", async ({ driver }) => { + test("EXAMPLE: Failed messages should be ordered by failure time by default", async ({ driver }) => { const { oldest, middle, newest } = failedMessagesFixture(); await givenFailedMessagesAreShown(driver, asList({ oldest, middle, newest })); From 764c5261e54fbe9f40f43378b3bf5fa7324beeb7 Mon Sep 17 00:00:00 2001 From: afprtclr Date: Tue, 29 Sep 2026 21:05:04 +0200 Subject: [PATCH 3/4] Fixing tests and adding hover --- .../failedmessages/actions/hoverFailedMessage.ts | 13 +++++++++++++ .../specs/failedmessages/actions/requestRetry.ts | 6 +++--- .../failedmessages/listing-failed-message.spec.ts | 8 +++++++- .../failedmessages/questions/failedMessageRows.ts | 7 +++++-- 4 files changed, 28 insertions(+), 6 deletions(-) create mode 100644 src/Frontend/test/specs/failedmessages/actions/hoverFailedMessage.ts diff --git a/src/Frontend/test/specs/failedmessages/actions/hoverFailedMessage.ts b/src/Frontend/test/specs/failedmessages/actions/hoverFailedMessage.ts new file mode 100644 index 0000000000..d40beefb03 --- /dev/null +++ b/src/Frontend/test/specs/failedmessages/actions/hoverFailedMessage.ts @@ -0,0 +1,13 @@ +import userEvent from "@testing-library/user-event"; + +function rowElement(messageId: string): HTMLElement { + const row = document.querySelector(`.row.box.repeat-item.failed-message #checkbox${messageId}`)?.closest(".failed-message"); + if (!row) { + throw new Error(`Failed message ${messageId} is not displayed`); + } + return row; +} + +export async function hoverFailedMessage(messageId: string): Promise { + await userEvent.hover(rowElement(messageId)); +} diff --git a/src/Frontend/test/specs/failedmessages/actions/requestRetry.ts b/src/Frontend/test/specs/failedmessages/actions/requestRetry.ts index dff45821a0..cfb80b7ed7 100644 --- a/src/Frontend/test/specs/failedmessages/actions/requestRetry.ts +++ b/src/Frontend/test/specs/failedmessages/actions/requestRetry.ts @@ -1,5 +1,5 @@ import userEvent from "@testing-library/user-event"; -import { fireEvent } from "@testing-library/vue"; +import { hoverFailedMessage } from "./hoverFailedMessage"; function retryButton(messageId: string): HTMLElement { const row = document.querySelector(`.row.box.repeat-item.failed-message #checkbox${messageId}`)?.closest(".failed-message"); @@ -11,6 +11,6 @@ function retryButton(messageId: string): HTMLElement { } export async function requestRetryFor(messageId: string): Promise { - const button = retryButton(messageId); - await userEvent.click(button); + await hoverFailedMessage(messageId); + await userEvent.click(retryButton(messageId)); } diff --git a/src/Frontend/test/specs/failedmessages/listing-failed-message.spec.ts b/src/Frontend/test/specs/failedmessages/listing-failed-message.spec.ts index e7809709fa..01314ca4be 100644 --- a/src/Frontend/test/specs/failedmessages/listing-failed-message.spec.ts +++ b/src/Frontend/test/specs/failedmessages/listing-failed-message.spec.ts @@ -25,6 +25,7 @@ import { selectionButtonText, } from "./questions/failedMessagesView"; import { clearFailedMessageSelection, selectAllFailedMessages, selectFailedMessage } from "./actions/failedMessageSelection"; +import { hoverFailedMessage } from "./actions/hoverFailedMessage"; import { openTab } from "./actions/openTab"; import { requestRetryFor } from "./actions/requestRetry"; import { sortBy, sortByDescending } from "./actions/sortFailedMessages"; @@ -192,9 +193,14 @@ describe("FEATURE: All Failed Messages", () => { const { middle } = failedMessagesFixture(); await givenFailedMessagesAreShown(driver, asList({ middle })); + expect(getFailedMessageRow(middle.id)?.isRequestRetryActionVisible).toBe(false); + + await hoverFailedMessage(middle.id); + const row = getFailedMessageRow(middle.id); + expect(row?.isHovered).toBe(true); expect(row?.isSelectable).toBe(true); - expect(row?.hasRequestRetryButton).toBe(true); + expect(row?.isRequestRetryActionVisible).toBe(true); }); }); diff --git a/src/Frontend/test/specs/failedmessages/questions/failedMessageRows.ts b/src/Frontend/test/specs/failedmessages/questions/failedMessageRows.ts index 252d8dff6f..5cce09ed79 100644 --- a/src/Frontend/test/specs/failedmessages/questions/failedMessageRows.ts +++ b/src/Frontend/test/specs/failedmessages/questions/failedMessageRows.ts @@ -14,7 +14,8 @@ export interface FailedMessageRow { exceptionMessage: string; isSelected: boolean; isSelectable: boolean; - hasRequestRetryButton: boolean; + isHovered: boolean; + isRequestRetryActionVisible: boolean; element: HTMLElement; } @@ -24,6 +25,7 @@ function queryRows(): FailedMessageRow[] { const messageTypeElement = element.querySelector("p.lead.break"); const spans = Array.from(element.querySelectorAll("p.metadata > span.metadata")).map((span) => normalise(span.textContent ?? "")); const retryBadge = Array.from(element.querySelectorAll("p.metadata > span")).find((span) => /retry failures/i.test(span.textContent ?? "")); + const requestRetryAction = element.querySelector('button[name="retryMessage"]'); return { id: checkbox?.id.replace(/^checkbox/, "") ?? "", @@ -37,7 +39,8 @@ function queryRows(): FailedMessageRow[] { exceptionMessage: normalise(element.querySelector("pre.stacktrace-preview")?.textContent ?? ""), isSelected: checkbox?.checked ?? false, isSelectable: checkbox !== null && !checkbox.disabled, - hasRequestRetryButton: element.querySelector('button[name="retryMessage"]') !== null, + isRequestRetryActionVisible: requestRetryAction !== null && getComputedStyle(requestRetryAction).display !== "none", + isHovered: element.matches(":hover"), element, }; }); From 4953e48b99bc939485a7e3b9f2012f5bbcb2fdce Mon Sep 17 00:00:00 2001 From: afprtclr Date: Tue, 29 Sep 2026 21:37:50 +0200 Subject: [PATCH 4/4] Fixing race condition --- .../specs/failedmessages/listing-failed-message.spec.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Frontend/test/specs/failedmessages/listing-failed-message.spec.ts b/src/Frontend/test/specs/failedmessages/listing-failed-message.spec.ts index 01314ca4be..99d16627af 100644 --- a/src/Frontend/test/specs/failedmessages/listing-failed-message.spec.ts +++ b/src/Frontend/test/specs/failedmessages/listing-failed-message.spec.ts @@ -67,6 +67,13 @@ async function givenFailedMessagesInAPaymentsGroupAreShown(driver: Driver): Prom const bed = await driver.setUp(precondition.hasFailedMessages({ messages: asList(fixture), groups })); await driver.goTo(routeLinks.failedMessage.group.link(GROUP_ONE)); await waitFor(() => expect(getFailedMessageRowCount()).toBe(groups[0].messageIds.length), { timeout: 5000 }); + await waitFor( + () => { + expect(groupHeading()).toBe(GROUP_ONE_TITLE); + expect(groupMessageCount()).toBe(`${groups[0].messageIds.length} messages in group`); + }, + { timeout: 5000 } + ); return bed; }