diff --git a/src/Frontend/test/preconditions/deletableFailedMessage.ts b/src/Frontend/test/preconditions/deletableFailedMessage.ts new file mode 100644 index 000000000..bc155feae --- /dev/null +++ b/src/Frontend/test/preconditions/deletableFailedMessage.ts @@ -0,0 +1,40 @@ +import type { SetupFactoryOptions } from "../driver"; +import { FailedMessageStatus, type FailedMessage } from "@/resources/FailedMessage"; +import { hasFailedMessage } from "./recoverability"; + +export interface DeletableFailedMessageTestBed { + failedMessage: FailedMessage; + deletedIds: string[]; +} + +export const hasDeletableFailedMessage = + ( + { + withGroupId, + withMessageId, + withContentType = "application/json", + withBody = { Index: 0, Data: "" }, + }: { withGroupId: string; withMessageId: string; withContentType?: string; withBody?: Record | string | number | boolean | null | undefined } = { withGroupId: "", withMessageId: "" } + ) => + async ({ driver }: SetupFactoryOptions): Promise => { + const serviceControlUrl = window.defaultConfig.service_control_url; + const failedMessage = await driver.setUp(hasFailedMessage({ withGroupId, withMessageId, withContentType, withBody })); + + let status: FailedMessageStatus = FailedMessageStatus.Unresolved; + const deletedIds: string[] = []; + + driver.mockEndpointDynamic(`${serviceControlUrl}errors/last/:groupId`, "get", (_url, params) => + Promise.resolve({ + body: { ...failedMessage, id: String(params.groupId), status }, + }) + ); + + driver.mockEndpointDynamic(`${serviceControlUrl}errors/archive`, "patch", async (_url, _params, request) => { + const ids = ((await request.json()) as string[]) ?? []; + deletedIds.push(...ids); + status = FailedMessageStatus.Archived; + return { body: {} }; + }); + + return { failedMessage, deletedIds }; + }; diff --git a/src/Frontend/test/preconditions/deletedMessageGroups.ts b/src/Frontend/test/preconditions/deletedMessageGroups.ts new file mode 100644 index 000000000..6cab591ec --- /dev/null +++ b/src/Frontend/test/preconditions/deletedMessageGroups.ts @@ -0,0 +1,63 @@ +import dayjs from "@/utils/dayjs"; +import type FailureGroupView from "@/resources/FailureGroupView"; +import type { SetupFactoryOptions } from "../driver"; + +export type DeletedMessageGroupRow = FailureGroupView & { last_operation_completion_time?: string }; + +export interface DeletedMessageGroupOptions { + title?: string; + type?: string; + count?: number; + comment?: string; + firstFailedDaysAgo?: number; + lastFailedDaysAgo?: number; + lastRetriedDaysAgo?: number; +} + +export const createDeletedMessageGroup = ( + id: string, + { title = `Deleted group ${id}`, type = "Endpoint Name", count = 1, comment = "", firstFailedDaysAgo = 5, lastFailedDaysAgo = 1, lastRetriedDaysAgo }: DeletedMessageGroupOptions = {} +): DeletedMessageGroupRow => { + const group: DeletedMessageGroupRow = { + id, + title, + type, + count, + comment, + first: dayjs().subtract(firstFailedDaysAgo, "day").toISOString(), + last: dayjs().subtract(lastFailedDaysAgo, "day").toISOString(), + }; + + if (lastRetriedDaysAgo !== undefined) { + group.last_operation_completion_time = dayjs().subtract(lastRetriedDaysAgo, "day").toISOString(); + } + + return group; +}; + +export interface DeletedMessageGroupsTestBed { + groups: DeletedMessageGroupRow[]; + restoredGroupIds: string[]; +} + +export const hasDeletedMessageGroups = + ({ groups = [] }: { groups?: DeletedMessageGroupRow[] } = {}) => + ({ driver }: SetupFactoryOptions): DeletedMessageGroupsTestBed => { + const serviceControlUrl = window.defaultConfig.service_control_url; + const table = [...groups]; + const restoredGroupIds: string[] = []; + + driver.mockEndpointDynamic(`${serviceControlUrl}errors/groups{/:classifier}`, "get", () => Promise.resolve({ body: [...table] })); + + driver.mockEndpointDynamic(`${serviceControlUrl}recoverability/groups/:groupId/errors/unarchive`, "post", (_url, params) => { + const groupId = String(params.groupId); + const index = table.findIndex((group) => group.id === groupId); + if (index >= 0) { + table.splice(index, 1); + } + restoredGroupIds.push(groupId); + return Promise.resolve({ body: {} }); + }); + + return { groups: table, restoredGroupIds }; + }; diff --git a/src/Frontend/test/preconditions/index.ts b/src/Frontend/test/preconditions/index.ts index df3c0ec19..5017ff767 100644 --- a/src/Frontend/test/preconditions/index.ts +++ b/src/Frontend/test/preconditions/index.ts @@ -16,6 +16,8 @@ export * from "../preconditions/hasHeartbeatEndpoints"; export { serviceControlWithMonitoring } from "./serviceControlWithMonitoring"; export * from "./recoverability"; export * from "./deletedMessages"; +export * from "./deletedMessageGroups"; +export * from "./deletableFailedMessage"; export * from "./licensing"; export { hasLicensingReportAvailable } from "../preconditions/hasLicensingReportAvailable"; export { hasLicensingSettingTest } from "../preconditions/hasLicensingSettingTest"; diff --git a/src/Frontend/test/preconditions/recoverability.ts b/src/Frontend/test/preconditions/recoverability.ts index 091bba9d8..14f1c7669 100644 --- a/src/Frontend/test/preconditions/recoverability.ts +++ b/src/Frontend/test/preconditions/recoverability.ts @@ -129,6 +129,26 @@ export const recoverabilityEditConfigDefaultHandler = ({ driver }: SetupFactoryO }); }; +export interface EditAndRetryConfigTestBed { + wasServed: () => boolean; +} + +export const hasEditAndRetryConfig = + (enabled: boolean) => + ({ driver }: SetupFactoryOptions): EditAndRetryConfigTestBed => { + const serviceControlUrl = window.defaultConfig.service_control_url; + const config = structuredClone(editConfig); + config.enabled = enabled; + let served = false; + + driver.mockEndpointDynamic(`${serviceControlUrl}edit/config`, "get", () => { + served = true; + return Promise.resolve({ body: config }); + }); + + return { wasServed: () => served }; + }; + export const enableEditAndRetry = ({ driver }: SetupFactoryOptions) => { const serviceControlUrl = window.defaultConfig.service_control_url; const config = structuredClone(editConfig); @@ -280,4 +300,6 @@ export const hasFailedMessage = driver.mockEndpoint(`${serviceControlUrl}recoverability/groups/id/${withGroupId}`, { body: { id: withGroupId, title: "Endpoint1", type: "Endpoint Name", count: 1, first: "2024-06-27T06:14:48.912923Z", last: "2024-06-27T06:14:48.912923Z" }, }); + + return failedMessage; }; diff --git a/src/Frontend/test/specs/failedmessages/actions/deleteMessage.ts b/src/Frontend/test/specs/failedmessages/actions/deleteMessage.ts new file mode 100644 index 000000000..ef98425af --- /dev/null +++ b/src/Frontend/test/specs/failedmessages/actions/deleteMessage.ts @@ -0,0 +1,20 @@ +import userEvent from "@testing-library/user-event"; +import { screen, within } from "@testing-library/vue"; +import { getDeleteConfirmation } from "../questions/messageDetails"; + +export async function clickDeleteMessage(): Promise { + await userEvent.click(screen.getByRole("button", { name: /^delete message$/i })); +} + +export async function confirmDeleteMessage(): Promise { + await userEvent.click(within(getDeleteConfirmation()).getByRole("button", { name: "Yes" })); +} + +export async function declineDeleteMessage(): Promise { + await userEvent.click(within(getDeleteConfirmation()).getByRole("button", { name: "No" })); +} + +export async function deleteMessage(): Promise { + await clickDeleteMessage(); + await confirmDeleteMessage(); +} diff --git a/src/Frontend/test/specs/failedmessages/actions/restoreDeletedMessageGroups.ts b/src/Frontend/test/specs/failedmessages/actions/restoreDeletedMessageGroups.ts new file mode 100644 index 000000000..2641f4fed --- /dev/null +++ b/src/Frontend/test/specs/failedmessages/actions/restoreDeletedMessageGroups.ts @@ -0,0 +1,25 @@ +import userEvent from "@testing-library/user-event"; +import { within } from "@testing-library/vue"; +import { getRestoreGroupConfirmation } from "../questions/deletedMessageGroupsView"; +import { getDeletedMessageGroupRow } from "../questions/deletedMessageGroupRows"; + +export async function clickRestoreGroup(groupTitle: string): Promise { + const row = getDeletedMessageGroupRow(groupTitle); + if (!row) { + throw new Error(`Cannot restore: no deleted message group row titled "${groupTitle}" is listed`); + } + await userEvent.click(within(row.element).getByRole("button", { name: /restore group/i })); +} + +export async function confirmRestoreGroup(): Promise { + await userEvent.click(within(getRestoreGroupConfirmation()).getByRole("button", { name: "Yes" })); +} + +export async function declineRestoreGroup(): Promise { + await userEvent.click(within(getRestoreGroupConfirmation()).getByRole("button", { name: "No" })); +} + +export async function restoreGroup(groupTitle: string): Promise { + await clickRestoreGroup(groupTitle); + await confirmRestoreGroup(); +} diff --git a/src/Frontend/test/specs/failedmessages/deleting-failed-message-groups.spec.ts b/src/Frontend/test/specs/failedmessages/deleting-failed-message-groups.spec.ts index 6db8ee5dd..9a2d51aa9 100644 --- a/src/Frontend/test/specs/failedmessages/deleting-failed-message-groups.spec.ts +++ b/src/Frontend/test/specs/failedmessages/deleting-failed-message-groups.spec.ts @@ -1,54 +1,109 @@ +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 { DeletedMessageGroupsTestBed } from "../../preconditions/deletedMessageGroups"; +import routeLinks from "@/router/routeLinks"; +import { getDeletedMessageGroupRowCount, getDeletedMessageGroupRow } from "./questions/deletedMessageGroupRows"; +import { isEmptyGroupsMessageVisible, isDismissButtonVisible, isRestoreCompletedVisible, isRestoreGroupConfirmationVisible } from "./questions/deletedMessageGroupsView"; +import { clickRestoreGroup, confirmRestoreGroup } from "./actions/restoreDeletedMessageGroups"; + +const DELETED_MESSAGE_GROUPS = routeLinks.failedMessage.deletedMessagesGroup.link; + +async function givenDeletedMessageGroupsAreShown(driver: Driver, titles: string[]): Promise { + await driver.setUp(precondition.serviceControlWithMonitoring); + const groups = titles.map((title, index) => precondition.createDeletedMessageGroup(`group-${index + 1}`, { title, count: 3, firstFailedDaysAgo: 5, lastFailedDaysAgo: 2, lastRetriedDaysAgo: 3 })); + const bed = await driver.setUp(precondition.hasDeletedMessageGroups({ groups })); + + await driver.goTo(DELETED_MESSAGE_GROUPS); + await waitFor(() => expect(getDeletedMessageGroupRowCount()).toBe(groups.length), { timeout: 5000 }); + + return bed; +} describe("FEATURE: Deleted Message Groups", () => { describe("RULE: Deleted Message Groups view should shows all current deleted messages, grouped by the selected grouping", () => { - test.todo("EXAMPLE: A message should be show when there are no deleted messages"); - - /* SCENARIO - Given there are no Deleted Messages - Then the "Deleted Message Groups" tab will display a message indicating the fact - */ - - test.todo("EXAMPLE: The number of deleted messages in a group should be shown"); - test.todo("EXAMPLE: The time period from the first failed message should be shown"); - test.todo("EXAMPLE: The time period of the last failed message should be shown"); - test.todo("EXAMPLE: The time period of when the group was last retried should be shown"); - test.todo("EXAMPLE: A deleted message group that has not been retried should show N/A for the last retry time"); - - /* SCENARIO - Given there are 1 or more groups shown on the "Deleted Message Groups" tab - Then the group row will display the current grouping name in bold - and the group will display the number of messages in the group - and the group will display a time period indicating how long ago the first failure happened - and the group will display a time period indicating how long ago the last failure happened - and the group will display a time period indicating how long ago the group was last retried, or N/A if never retried - */ + test("EXAMPLE: A message should be shown when there are no deleted messages", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasDeletedMessageGroups({ groups: [] })); + await driver.goTo(DELETED_MESSAGE_GROUPS); + + await waitFor(() => expect(isEmptyGroupsMessageVisible()).toBe(true)); + expect(getDeletedMessageGroupRowCount()).toBe(0); + }); + + test("EXAMPLE: The name of a deleted message group should be shown in bold", async ({ driver }) => { + await givenDeletedMessageGroupsAreShown(driver, ["Payments failures"]); + + const row = getDeletedMessageGroupRow("Payments failures"); + expect(row?.title).toBe("Payments failures"); + expect(row?.titleIsBold).toBe(true); + }); + + test("EXAMPLE: The number of deleted messages in a group should be shown", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasDeletedMessageGroups({ groups: [precondition.createDeletedMessageGroup("group-1", { title: "Payments failures", count: 7 })] })); + await driver.goTo(DELETED_MESSAGE_GROUPS); + await waitFor(() => expect(getDeletedMessageGroupRowCount()).toBe(1)); + + expect(getDeletedMessageGroupRow("Payments failures")?.messageCount).toBe("7 messages"); + }); + + test("EXAMPLE: The time period from the first failed message should be shown", async ({ driver }) => { + await givenDeletedMessageGroupsAreShown(driver, ["Payments failures"]); + + expect(getDeletedMessageGroupRow("Payments failures")?.firstFailed).toBe("5 days ago"); + }); + + test("EXAMPLE: The time period of the last failed message should be shown", async ({ driver }) => { + await givenDeletedMessageGroupsAreShown(driver, ["Payments failures"]); + + expect(getDeletedMessageGroupRow("Payments failures")?.lastFailed).toBe("2 days ago"); + }); + + test("EXAMPLE: The time period of when the group was last retried should be shown", async ({ driver }) => { + await givenDeletedMessageGroupsAreShown(driver, ["Payments failures"]); + + expect(getDeletedMessageGroupRow("Payments failures")?.lastRetried).toBe("3 days ago"); + }); + + test("EXAMPLE: A deleted message group that has not been retried should show N/A for the last retry time", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasDeletedMessageGroups({ groups: [precondition.createDeletedMessageGroup("group-1", { title: "Payments failures" })] })); + await driver.goTo(DELETED_MESSAGE_GROUPS); + await waitFor(() => expect(getDeletedMessageGroupRowCount()).toBe(1)); + + expect(getDeletedMessageGroupRow("Payments failures")?.lastRetried).toMatch(/n\/a/i); + }); }); describe("RULE: All messages in a Deleted Message Group should be able to be restored in a single action", () => { - test.todo("EXAMPLE: A restore button should be shown when there are deleted messages in a group"); - /* SCENARIO - Given there are 1 or more groups shown on the "Deleted Message Groups" tab - Then "Restore group" is shown as an available action on the group - */ - - /* SCENARIO - Given there are 1 or more groups shown on the "Deleted Message Groups" tab - When the user clicks the "Restore group" action - and clicks "Yes" on the action confirmation modal - Then all the messages in the selected group are returned to the Failed Messages list - and the list refreshes with progress/confirmation of the successful restoration - */ - }); - describe("RULE: Ability to select a given group should be hinted ", () => { - test.todo("EXAMPLE: A group should indicate that it is active and selectable when the cursor is hovered over"); + test("EXAMPLE: A restore button should be shown when there are deleted messages in a group", async ({ driver }) => { + await givenDeletedMessageGroupsAreShown(driver, ["Payments failures"]); + + const row = getDeletedMessageGroupRow("Payments failures"); + expect(row?.restoreGroupText).toBe("Restore group"); + expect(row?.restoreGroupIsDisabled).toBe(false); + }); + + test("EXAMPLE: Restoring a group restores all its messages and the list refreshes with confirmation", async ({ driver }) => { + const bed = await givenDeletedMessageGroupsAreShown(driver, ["Payments failures"]); + + await clickRestoreGroup("Payments failures"); + expect(isRestoreGroupConfirmationVisible()).toBe(true); + await confirmRestoreGroup(); + + await waitFor(() => expect(bed.restoredGroupIds).toEqual(["group-1"])); - /* SCENARIO - Mouse hovering a group + await waitFor( + () => { + expect(isRestoreCompletedVisible()).toBe(true); + expect(isDismissButtonVisible()).toBe(true); + }, + { timeout: 5000 } + ); - Given there are 1 or more groups show in the "Deleted Message Groups" tab - and the user hovers over a Deleted Message Group row - Then the row indicates that it is active (hover state) - and that it is selectable (cursor/underlining) - */ + expect(getDeletedMessageGroupRow("Payments failures")?.restoreGroupText).toBeNull(); + }); }); }); diff --git a/src/Frontend/test/specs/failedmessages/deleting-failed-messages.spec.ts b/src/Frontend/test/specs/failedmessages/deleting-failed-messages.spec.ts index 2e60d82ce..9c1a95798 100644 --- a/src/Frontend/test/specs/failedmessages/deleting-failed-messages.spec.ts +++ b/src/Frontend/test/specs/failedmessages/deleting-failed-messages.spec.ts @@ -1,7 +1,58 @@ +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 { DeletableFailedMessageTestBed } from "../../preconditions/deletableFailedMessage"; +import { hasMessageStatusLabel, isDeleteConfirmationVisible, isDeleteMessageButtonVisible, isRestoreMessageButtonVisible, messageTypeTitle } from "./questions/messageDetails"; +import { clickDeleteMessage, confirmDeleteMessage } from "./actions/deleteMessage"; + +const GROUP_ID = "81dca64e-76fc-e1c3-11a2-3069f51c58c8"; +const MESSAGE_ID = "40134401-bab9-41aa-9acb-b19c0066f22d"; +const MESSAGE_TYPE = "ServiceControl.SmokeTest.SimpleCommand"; + +async function givenAFailedMessageIsDisplayed(driver: Driver): Promise { + await driver.setUp(precondition.serviceControlWithMonitoring); + const bed = await driver.setUp(precondition.hasDeletableFailedMessage({ withGroupId: GROUP_ID, withMessageId: MESSAGE_ID, withContentType: "application/json", withBody: { Index: 0, Data: "" } })); + + await driver.goTo(`messages/${GROUP_ID}`); + await waitFor(() => expect(messageTypeTitle()).toBe(MESSAGE_TYPE)); + + return bed; +} describe("FEATURE: Deleting failed messages", () => { describe("RULE: All failed messages should be able to be deleted", () => { - test.todo("EXAMPLE: A delete message button should be shown when viewing the message details"); + test("EXAMPLE: A delete message button should be shown when viewing the message details", async ({ driver }) => { + await givenAFailedMessageIsDisplayed(driver); + + expect(isDeleteMessageButtonVisible()).toBe(true); + }); + + test("EXAMPLE: Clicking the delete message button asks the user to confirm", async ({ driver }) => { + await givenAFailedMessageIsDisplayed(driver); + + await clickDeleteMessage(); + + expect(isDeleteConfirmationVisible()).toBe(true); + }); + + test("EXAMPLE: Confirming the deletion deletes the message and the view shows it as deleted", async ({ driver }) => { + const bed = await givenAFailedMessageIsDisplayed(driver); + await clickDeleteMessage(); + + await confirmDeleteMessage(); + + await waitFor(() => expect(bed.deletedIds).toEqual([GROUP_ID])); + + await waitFor( + () => { + expect(isDeleteMessageButtonVisible()).toBe(false); + expect(isRestoreMessageButtonVisible()).toBe(true); + expect(hasMessageStatusLabel(/^Deleted$/)).toBe(true); + }, + { timeout: 5000 } + ); + }); }); }); diff --git a/src/Frontend/test/specs/failedmessages/edit-and-retry.spec.ts b/src/Frontend/test/specs/failedmessages/edit-and-retry.spec.ts index 44e5bee2b..c51c66419 100644 --- a/src/Frontend/test/specs/failedmessages/edit-and-retry.spec.ts +++ b/src/Frontend/test/specs/failedmessages/edit-and-retry.spec.ts @@ -1,30 +1,50 @@ import { test, describe } from "../../drivers/vitest/driver"; import * as precondition from "../../preconditions"; +import type { EditAndRetryConfigTestBed } from "../../preconditions/recoverability"; +import type { Driver } from "../../driver"; import { openEditAndRetryEditor } from "./actions/openEditAndRetryEditor"; import { getEditAndRetryEditor } from "./questions/getEditAndRetryEditor"; +import { isEditAndRetryButtonVisible, messageTypeTitle } from "./questions/messageDetails"; import { expect } from "vitest"; +import { waitFor } from "@testing-library/vue"; + +const GROUP_ID = "81dca64e-76fc-e1c3-11a2-3069f51c58c8"; +const MESSAGE_ID = "40134401-bab9-41aa-9acb-b19c0066f22d"; +const MESSAGE_TYPE = "ServiceControl.SmokeTest.SimpleCommand"; + +async function givenAFailedMessageIsDisplayed(driver: Driver, allowEditing: boolean): Promise { + await driver.setUp(precondition.serviceControlWithMonitoring); + const editConfig = await driver.setUp(precondition.hasEditAndRetryConfig(allowEditing)); + await driver.setUp( + precondition.hasFailedMessage({ + withGroupId: GROUP_ID, + withMessageId: MESSAGE_ID, + withContentType: "application/json", + withBody: { Index: 0, Data: "" }, + }) + ); + + await driver.goTo(`messages/${GROUP_ID}`); + await waitFor(() => expect(messageTypeTitle()).toBe(MESSAGE_TYPE)); + + await waitFor(() => expect(editConfig.wasServed()).toBe(true)); + + return editConfig; +} describe("FEATURE: Editing failed messages", () => { describe("RULE: Editing of a message should only be allowed when ServiceControl 'AllowMessageEditing' is enabled", () => { - test.todo( - "EXAMPLE: ServiceControl 'AllowMessageEditing' is disabled" - /* - Given a failed message is displayed in the Failed Messages list - and the ServiceControl 'AllowMessageEditing' is disabled - When the user sees the details of the message - Then button for editing the message is not shown - */ - ); - - test.todo( - "EXAMPLE: ServiceControl 'AllowMessageEditing' is enabled" - /* - Given a failed message is displayed in the Failed Messages list - and the ServiceControl 'AllowMessageEditing' is enabled - When the user sees the details of the message - Then button for editing the message is shown - */ - ); + test("EXAMPLE: ServiceControl 'AllowMessageEditing' is disabled", async ({ driver }) => { + await givenAFailedMessageIsDisplayed(driver, false); + + expect(isEditAndRetryButtonVisible()).toBe(false); + }); + + test("EXAMPLE: ServiceControl 'AllowMessageEditing' is enabled", async ({ driver }) => { + await givenAFailedMessageIsDisplayed(driver, true); + + await waitFor(() => expect(isEditAndRetryButtonVisible()).toBe(true)); + }); }); describe("RULE: Only messages with with a content-type that is editable text should be allowed to be edited", () => { diff --git a/src/Frontend/test/specs/failedmessages/questions/deletedMessageGroupRows.ts b/src/Frontend/test/specs/failedmessages/questions/deletedMessageGroupRows.ts new file mode 100644 index 000000000..446d81e83 --- /dev/null +++ b/src/Frontend/test/specs/failedmessages/questions/deletedMessageGroupRows.ts @@ -0,0 +1,56 @@ +import { isBold, labelledValue, normalise } from "./domText"; + +const ROW_SELECTOR = ".row.box.box-group.deleted-message-group"; + +export interface DeletedMessageGroupRow { + title: string; + titleIsBold: boolean; + messageCount: string; + firstFailed: string; + lastFailed: string; + /** "n/a" for a group that has never been retried or restored. */ + lastRetried: string; + restoreGroupText: string | null; + restoreGroupIsDisabled: boolean; + element: HTMLElement; +} + +function restoreGroupButton(row: HTMLElement): HTMLButtonElement | null { + return Array.from(row.querySelectorAll("button")).find((button) => /restore group/i.test(button.textContent ?? "")) ?? null; +} + +function messageCountValue(spans: string[]): string { + return spans.find((span) => /^\d+ messages?$/.test(span)) ?? ""; +} + +function queryRows(): DeletedMessageGroupRow[] { + return Array.from(document.querySelectorAll(ROW_SELECTOR)).map((element) => { + const titleElement = element.querySelector("p.lead.break"); + const spans = Array.from(element.querySelectorAll("p.metadata > span.metadata")).map((span) => normalise(span.textContent ?? "")); + const restoreButton = restoreGroupButton(element); + + return { + title: normalise(titleElement?.textContent ?? ""), + titleIsBold: isBold(titleElement), + messageCount: messageCountValue(spans), + firstFailed: labelledValue(spans, "First failed:"), + lastFailed: labelledValue(spans, "Last failed:"), + lastRetried: labelledValue(spans, "Last retried:"), + restoreGroupText: restoreButton ? normalise(restoreButton.textContent ?? "") : null, + restoreGroupIsDisabled: restoreButton?.disabled ?? true, + element, + }; + }); +} + +export function getDeletedMessageGroupRows(): DeletedMessageGroupRow[] { + return queryRows(); +} + +export function getDeletedMessageGroupRowCount(): number { + return queryRows().length; +} + +export function getDeletedMessageGroupRow(title: string): DeletedMessageGroupRow | undefined { + return queryRows().find((row) => row.title === title); +} diff --git a/src/Frontend/test/specs/failedmessages/questions/deletedMessageGroupsView.ts b/src/Frontend/test/specs/failedmessages/questions/deletedMessageGroupsView.ts new file mode 100644 index 000000000..e57224957 --- /dev/null +++ b/src/Frontend/test/specs/failedmessages/questions/deletedMessageGroupsView.ts @@ -0,0 +1,33 @@ +import { screen } from "@testing-library/vue"; +import { normalise } from "./domText"; + +const RESTORE_GROUP_CONFIRMATION_NAME = /restore this group/i; + +export function noGroupedMessagesMessage(): string | null { + const message = screen.queryByText(/there are currently no grouped message failures/i); + return message && message.getAttribute("role") === "status" ? normalise(message.textContent ?? "") : null; +} + +export function isEmptyGroupsMessageVisible(): boolean { + return noGroupedMessagesMessage() !== null; +} + +export function isRestoreGroupConfirmationVisible(): boolean { + return screen.queryByRole("dialog", { name: RESTORE_GROUP_CONFIRMATION_NAME }) !== null; +} + +export function getRestoreGroupConfirmation(): HTMLElement { + return screen.getByRole("dialog", { name: RESTORE_GROUP_CONFIRMATION_NAME }) as HTMLElement; +} + +export function isRestoreInProgressVisible(): boolean { + return screen.queryByText(/restore request in progress/i) !== null; +} + +export function isRestoreCompletedVisible(): boolean { + return screen.queryByText(/restore request completed/i) !== null; +} + +export function isDismissButtonVisible(): boolean { + return screen.queryByRole("button", { name: /^dismiss$/i }) !== null; +} diff --git a/src/Frontend/test/specs/failedmessages/questions/deletedMessageRows.ts b/src/Frontend/test/specs/failedmessages/questions/deletedMessageRows.ts index 803c08768..529845d2c 100644 --- a/src/Frontend/test/specs/failedmessages/questions/deletedMessageRows.ts +++ b/src/Frontend/test/specs/failedmessages/questions/deletedMessageRows.ts @@ -1,3 +1,5 @@ +import { isBold, labelledValue, normalise } from "./domText"; + const ROW_SELECTOR = ".row.box.repeat-item.failed-message"; export interface DeletedMessageRow { @@ -18,24 +20,6 @@ export interface DeletedMessageRow { element: HTMLElement; } -function normalise(value: string): string { - return value.replace(/\s+/g, " ").trim(); -} - -function labelledValue(spans: string[], label: string): string { - const span = spans.find((candidate) => candidate.startsWith(label)); - return span ? normalise(span.slice(label.length)) : ""; -} - -function isBold(element: HTMLElement | null): boolean { - if (!element) { - return false; - } - const fontWeight = getComputedStyle(element).fontWeight; - const numericWeight = Number.parseInt(fontWeight, 10); - return fontWeight === "bold" || fontWeight === "bolder" || (!Number.isNaN(numericWeight) && numericWeight >= 600); -} - function queryRows(): DeletedMessageRow[] { return Array.from(document.querySelectorAll(ROW_SELECTOR)).map((element) => { const checkbox = element.querySelector("input.checkbox"); diff --git a/src/Frontend/test/specs/failedmessages/questions/deletedMessagesView.ts b/src/Frontend/test/specs/failedmessages/questions/deletedMessagesView.ts index 51f074a59..f58c934cb 100644 --- a/src/Frontend/test/specs/failedmessages/questions/deletedMessagesView.ts +++ b/src/Frontend/test/specs/failedmessages/questions/deletedMessagesView.ts @@ -1,11 +1,8 @@ import { screen } from "@testing-library/vue"; +import { normalise } from "./domText"; const RESTORE_CONFIRMATION_NAME = /restore the selected messages/i; -function normalise(value: string): string { - return value.replace(/\s+/g, " ").trim(); -} - function tabElements(): HTMLElement[] { return Array.from(document.querySelectorAll(".tabs h5")); } diff --git a/src/Frontend/test/specs/failedmessages/questions/domText.ts b/src/Frontend/test/specs/failedmessages/questions/domText.ts new file mode 100644 index 000000000..dad3602d2 --- /dev/null +++ b/src/Frontend/test/specs/failedmessages/questions/domText.ts @@ -0,0 +1,17 @@ +export function normalise(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +export function labelledValue(spans: string[], label: string): string { + const span = spans.find((candidate) => candidate.startsWith(label)); + return span ? normalise(span.slice(label.length)) : ""; +} + +export function isBold(element: HTMLElement | null): boolean { + if (!element) { + return false; + } + const fontWeight = getComputedStyle(element).fontWeight; + const numericWeight = Number.parseInt(fontWeight, 10); + return fontWeight === "bold" || fontWeight === "bolder" || (!Number.isNaN(numericWeight) && numericWeight >= 600); +} diff --git a/src/Frontend/test/specs/failedmessages/questions/messageDetails.ts b/src/Frontend/test/specs/failedmessages/questions/messageDetails.ts new file mode 100644 index 000000000..98a259f57 --- /dev/null +++ b/src/Frontend/test/specs/failedmessages/questions/messageDetails.ts @@ -0,0 +1,37 @@ +import { screen } from "@testing-library/vue"; +import { normalise } from "./domText"; + +const DELETE_CONFIRMATION_NAME = /delete this message/i; + +export function messageTypeTitle(): string | null { + const title = document.querySelector("h1.message-type-title"); + return title ? normalise(title.textContent ?? "") : null; +} + +export function isDeleteMessageButtonVisible(): boolean { + return screen.queryByRole("button", { name: /^delete message$/i }) !== null; +} + +export function isRestoreMessageButtonVisible(): boolean { + return screen.queryByRole("button", { name: /^restore$/i }) !== null; +} + +export function isEditAndRetryButtonVisible(): boolean { + return screen.queryByRole("button", { name: /^edit & retry$/i }) !== null; +} + +export function messageStatusLabels(): string[] { + return Array.from(document.querySelectorAll(".metadata-label")).map((label) => normalise(label.textContent ?? "")); +} + +export function hasMessageStatusLabel(label: RegExp): boolean { + return messageStatusLabels().some((candidate) => label.test(candidate)); +} + +export function isDeleteConfirmationVisible(): boolean { + return screen.queryByRole("dialog", { name: DELETE_CONFIRMATION_NAME }) !== null; +} + +export function getDeleteConfirmation(): HTMLElement { + return screen.getByRole("dialog", { name: DELETE_CONFIRMATION_NAME }) as HTMLElement; +}