diff --git a/src/Frontend/test/preconditions/hasHeartbeatEndpoints.ts b/src/Frontend/test/preconditions/hasHeartbeatEndpoints.ts index 7fbaecc7f2..5beaceccb0 100644 --- a/src/Frontend/test/preconditions/hasHeartbeatEndpoints.ts +++ b/src/Frontend/test/preconditions/hasHeartbeatEndpoints.ts @@ -182,3 +182,8 @@ export const HasHealthyAndUnHealthyNamedEndpoints = (numberOfHealthyEndpoints: n }; export const HasHealthyAndUnHealthyEndpoints = (numberOfHealthyEndpoints: number, numberOfUnhealthyEndpoints: number) => HasHealthyAndUnHealthyNamedEndpoints(numberOfHealthyEndpoints, numberOfUnhealthyEndpoints, "TestEndpoint"); + +export const heartbeatEndpointNames = (endpointNamePrefix: string, count: number) => Array.from({ length: count }, (_unused, index) => `${endpointNamePrefix}_${index}`); + +export const endpointsWithHeartbeatStatus = (sendingNames: string[], stoppedNames: string[] = []) => + hasHeartbeatsEndpoints([...sendingNames.map((name) => ({ ...healthyEndpointTemplate, id: name, name })), ...stoppedNames.map((name) => ({ ...unHealthyEndpointTemplate, id: name, name }))]); diff --git a/src/Frontend/test/specs/heartbeats/actions/navigateToEndpointInstances.ts b/src/Frontend/test/specs/heartbeats/actions/navigateToEndpointInstances.ts new file mode 100644 index 0000000000..72c1c893ea --- /dev/null +++ b/src/Frontend/test/specs/heartbeats/actions/navigateToEndpointInstances.ts @@ -0,0 +1,27 @@ +import { screen, within } from "@testing-library/vue"; +import UserEvent from "@testing-library/user-event"; +import type { HeartbeatsTab } from "../questions/getHeartbeatsTabCount"; + +const tabRegionName: Record = { + active: "Healthy Endpoints", + inactive: "Unhealthy Endpoints", +}; + +const destinationReached = { timeout: 5000 }; + +export async function navigateToEndpointInstances(endpointName: string) { + const endpointRow = await screen.findByRole("row", { name: endpointName }); + const detailsLink = within(endpointRow).getByRole("link", { name: "details-link" }); + + await UserEvent.click(detailsLink); + + await screen.findByRole("heading", { name: `${endpointName} Instances` }, destinationReached); +} + +export async function navigateBackToEndpointList(tab: HeartbeatsTab) { + const backLink = await screen.findByRole("link", { name: "Back" }); + + await UserEvent.click(backLink); + + await screen.findByRole("region", { name: tabRegionName[tab] }, destinationReached); +} diff --git a/src/Frontend/test/specs/heartbeats/actions/navigateToHeartbeatsTabs.ts b/src/Frontend/test/specs/heartbeats/actions/navigateToHeartbeatsTabs.ts index e996f603d4..ef973583c7 100644 --- a/src/Frontend/test/specs/heartbeats/actions/navigateToHeartbeatsTabs.ts +++ b/src/Frontend/test/specs/heartbeats/actions/navigateToHeartbeatsTabs.ts @@ -10,7 +10,7 @@ export async function navigateToHeartbeatsConfiguration() { } export async function navigateToHealthyHeartbeats() { - const healthyHeartbeatsTab = await screen.findByRole("tab", { name: /Healthy Endpoints \(\d+\)/i }); + const healthyHeartbeatsTab = await screen.findByRole("tab", { name: /^\s*Healthy Endpoints \(\d+\)\s*$/i }); healthyHeartbeatsTab.click(); // Wait for the tab to switch diff --git a/src/Frontend/test/specs/heartbeats/actions/sortEndpointsBy.ts b/src/Frontend/test/specs/heartbeats/actions/sortEndpointsBy.ts new file mode 100644 index 0000000000..6a121be382 --- /dev/null +++ b/src/Frontend/test/specs/heartbeats/actions/sortEndpointsBy.ts @@ -0,0 +1,9 @@ +import { screen, within } from "@testing-library/vue"; +import UserEvent from "@testing-library/user-event"; + +export async function sortEndpointsBy(columnName: string) { + const columnHeader = await screen.findByRole("columnheader", { name: columnName }); + const sortButton = within(columnHeader).getByRole("button", { name: columnName }); + + await UserEvent.click(sortButton); +} diff --git a/src/Frontend/test/specs/heartbeats/active-endpoints.spec.ts b/src/Frontend/test/specs/heartbeats/active-endpoints.spec.ts index 2d58da3abc..a89934c0d3 100644 --- a/src/Frontend/test/specs/heartbeats/active-endpoints.spec.ts +++ b/src/Frontend/test/specs/heartbeats/active-endpoints.spec.ts @@ -1,104 +1,349 @@ +import { expect, vi } from "vitest"; +import { waitFor } from "@testing-library/vue"; import { test, describe } from "../../drivers/vitest/driver"; +import * as precondition from "../../preconditions"; +import { healthyEndpointTemplate, unHealthyEndpointTemplate } from "../../mocks/heartbeat-endpoint-template"; +import { getHeartbeatsTabCount } from "./questions/getHeartbeatsTabCount"; +import { getListedEndpointNames } from "./questions/getListedEndpointNames"; +import { getEndpointInstanceRows } from "./questions/getEndpointInstanceRows"; +import { getAllHeartbeatEndpointRecords, getHeartbeatEndpointRecord } from "./questions/getHeartbeatEndpointRecord"; +import { getColumnSortDirection } from "./questions/getColumnSortDirection"; +import { navigateToEndpointInstances, navigateBackToEndpointList } from "./actions/navigateToEndpointInstances"; +import { sortEndpointsBy } from "./actions/sortEndpointsBy"; +import { setHeartbeatFilter } from "./actions/setHeartbeatFilter"; + +vi.mock("@vueuse/core", async (importOriginal) => { + const originalModule = await importOriginal(); + return { + ...originalModule, + useDebounceFn: (fn: (...args: Args) => Result) => fn, + }; +}); + +const receivedHeartbeat = expect.stringMatching(/ago$/); describe("FEATURE: Active Endpoints", () => { describe("RULE: The number of active endpoints should be shown", () => { - test.todo("EXAMPLE: With 7 active endpoints, the tab should show (7)"); - - /* SCENARIO - Given 7 endpoint instances sending heartbeats - When the hearbeats screen is open - Then the Active Endpoints tab includes a (7) suffix - */ - - test.todo("EXAMPLE: With 7 active endpoints and 1 endpoint stop sending heartbeat, the tab should show (6)"); - /* SCENARIO - Given 7 endpoint instances sending heartbeats - When one of the endpoints stop sending heartbeats - Then the Active Endpoints tab suffix changes to (6) - */ - - test.todo("EXAMPLE: With 6 active endpoints and 1 endpoint starts sending heartbeat, the tab should show (7)"); - /* SCENARIO - Given 6 endpoint instances sending heartbeats - And 1 endpoint instance not sending heartbeats - When the stopped endpoint starts sending heartbeats - Then the Active Endpoints tab suffix changes to (7) - */ + test("EXAMPLE: With 7 active endpoints, the tab should show (7)", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasHealthyEndpoints(7)); + + await driver.goTo("heartbeats/healthy"); + + await waitFor(() => expect(getHeartbeatsTabCount("active")).toBe(7)); + }); + + test("EXAMPLE: With 7 active endpoints and 1 endpoint stop sending heartbeat, the tab should show (6)", async ({ driver }) => { + vi.useFakeTimers(); + + try { + const endpoints = precondition.heartbeatEndpointNames("ActiveEndpoint", 7); + const stoppedEndpoint = endpoints[endpoints.length - 1]; + const stillSending = endpoints.filter((name) => name !== stoppedEndpoint); + + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.endpointsWithHeartbeatStatus(endpoints)); + + await driver.goTo("heartbeats/healthy"); + await waitFor(() => expect(getHeartbeatsTabCount("active")).toBe(7)); + + await driver.setUp(precondition.endpointsWithHeartbeatStatus(stillSending, [stoppedEndpoint])); + vi.advanceTimersByTime(5000); + + await waitFor(() => expect(getHeartbeatsTabCount("active")).toBe(6)); + } finally { + vi.useRealTimers(); + } + }); + + test("EXAMPLE: With 6 active endpoints and 1 endpoint starts sending heartbeat, the tab should show (7)", async ({ driver }) => { + vi.useFakeTimers(); + + try { + const endpoints = precondition.heartbeatEndpointNames("RevivingEndpoint", 7); + const startingEndpoint = endpoints[endpoints.length - 1]; + const alreadySending = endpoints.filter((name) => name !== startingEndpoint); + + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.endpointsWithHeartbeatStatus(alreadySending, [startingEndpoint])); + + await driver.goTo("heartbeats/healthy"); + await waitFor(() => expect(getHeartbeatsTabCount("active")).toBe(6)); + + await driver.setUp(precondition.endpointsWithHeartbeatStatus([...alreadySending, startingEndpoint])); + vi.advanceTimersByTime(5000); + + await waitFor(() => expect(getHeartbeatsTabCount("active")).toBe(7)); + } finally { + vi.useRealTimers(); + } + }); }); + describe("RULE: A list of active endpoints should be shown", () => { - test.todo("EXAMPLE: With 3 active endpoints sending heartbeats, 3 endpoints should be shown in the list of active endpoints"); + test("EXAMPLE: With 3 active endpoints sending heartbeats, 3 endpoints should be shown in the list of active endpoints", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasHealthyEndpoints(3)); - /* SCENARIO - Display list of active endpoints + await driver.goTo("heartbeats/healthy"); - Given 3 endpoint instances sending heartbeats - When the Active Endpoints tab is open - Then the 3 endpoints are displayed - */ + await waitFor(() => expect(getListedEndpointNames()).toEqual(["HealthyHeartbeatEndpoint_0", "HealthyHeartbeatEndpoint_1", "HealthyHeartbeatEndpoint_2"])); + }); }); + describe("RULE: Active endpoint list row should show endpoint instances with name, host identifier, and latest heartbeat received", () => { - test.todo("EXAMPLE: With 3 active endpoint instances named 'Endpoint1' at host 'HOST1' sending a heartbeat, there should be 3 rows displaying 'Endpoint1@HOST1', and the latest heartbeat received"); + test("EXAMPLE: With 3 active endpoint instances named 'Endpoint1' at host 'HOST1' sending a heartbeat, there should be 3 rows displaying 'Endpoint1@HOST1', and the latest heartbeat received", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp( + precondition.hasHeartbeatsEndpoints( + [1, 2, 3].map((instance) => ({ + ...healthyEndpointTemplate, + id: `Endpoint1_instance${instance}`, + name: "Endpoint1", + host_display_name: "HOST1", + })) + ) + ); + + await driver.goTo("heartbeats/healthy"); - /* SCENARIO - Display Endpoint Instances + await waitFor(() => expect(getListedEndpointNames()).toEqual(["Endpoint1"])); - Given 3 endpoint instances sending heartbeats - When the Active Endpoints tab is open - Then the 3 endpoint instances are displayed - */ + const logicalEndpoint = await getHeartbeatEndpointRecord("Endpoint1"); - /* NOTES - Endpoint name - Host identifier - Latest heartbeat received + expect(logicalEndpoint?.instanceCount).toBe("3/3"); + expect(logicalEndpoint?.lastHeartbeat).toEqual(receivedHeartbeat); - */ + await navigateToEndpointInstances("Endpoint1"); + + expect(getEndpointInstanceRows()).toEqual([ + { hostName: "HOST1", lastHeartbeat: receivedHeartbeat }, + { hostName: "HOST1", lastHeartbeat: receivedHeartbeat }, + { hostName: "HOST1", lastHeartbeat: receivedHeartbeat }, + ]); + }); }); + describe("RULE: Active endpoint list row should show logical endpoint with name, number of instances, host identifier, and latest heartbeat received", () => { - test.todo("EXAMPLE: With multiple instances of an endpoints sending heartbeats, only the single logical endpoint details should be displayed in the list"); + test("EXAMPLE: With multiple instances of an endpoints sending heartbeats, only the single logical endpoint details should be displayed in the list", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp( + precondition.hasHeartbeatsEndpoints([ + { ...healthyEndpointTemplate, id: "Endpoint1_HOST1", name: "Endpoint1", host_display_name: "HOST1" }, + { ...healthyEndpointTemplate, id: "Endpoint1_HOST2", name: "Endpoint1", host_display_name: "HOST2" }, + { ...healthyEndpointTemplate, id: "Endpoint2_HOST1", name: "Endpoint2", host_display_name: "HOST1" }, + ]) + ); + + await driver.goTo("heartbeats/healthy"); + + await waitFor(() => expect(getListedEndpointNames()).toEqual(["Endpoint1", "Endpoint2"])); + + const records = await getAllHeartbeatEndpointRecords(); + + expect(records.map(({ name, instanceCount }) => ({ name, instanceCount }))).toEqual([ + { name: "Endpoint1", instanceCount: "2/2" }, + { name: "Endpoint2", instanceCount: "1/1" }, + ]); + expect(records.map(({ lastHeartbeat }) => lastHeartbeat)).toEqual([receivedHeartbeat, receivedHeartbeat]); + }); }); - /* SCENARIO - Display Logical Endpoints - - Given 3 endpoint instances sending heartbeats - Endpoint1@HOST1 - Endpoint1@HOST2 - Endpoint2@HOST1 - When the Active Endpoints tab is open - Then 2 logical endpoints are shown - Endpoint1 - Endpoint2 - */ - - /* NOTES - Endpoint name - Instance count - Latest heartbeat received - */ + describe("RULE: Changing between logical and instance listing displays should be possible", () => { - test.todo("Not implemented"); + test("EXAMPLE: Opening a logical endpoint from the active list shows its instances, and going back returns to the active list", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp( + precondition.hasHeartbeatsEndpoints([ + { ...healthyEndpointTemplate, id: "Endpoint1_HOST1", name: "Endpoint1", host_display_name: "HOST1" }, + { ...healthyEndpointTemplate, id: "Endpoint1_HOST2", name: "Endpoint1", host_display_name: "HOST2" }, + ]) + ); + + await driver.goTo("heartbeats/healthy"); + + await waitFor(() => expect(getListedEndpointNames()).toEqual(["Endpoint1"])); + + await navigateToEndpointInstances("Endpoint1"); + expect(getEndpointInstanceRows().map((row) => row.hostName)).toEqual(["HOST1", "HOST2"]); + + await navigateBackToEndpointList("active"); + + await waitFor(() => expect(getListedEndpointNames()).toEqual(["Endpoint1"])); + }); }); + describe("RULE: Sorting by of the name of an endpoint should be possible in all displays", () => { - test.todo("Not implemented"); + test("EXAMPLE: Active endpoints are listed by name ascending by default and can be sorted descending", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.endpointsWithHeartbeatStatus(["Foo1", "Foo2", "Foo3"])); + + await driver.goTo("heartbeats/healthy"); + + await waitFor(() => expect(getListedEndpointNames()).toEqual(["Foo1", "Foo2", "Foo3"])); + expect(getColumnSortDirection("name")).toBe("ascending"); + + await sortEndpointsBy("name"); + + await waitFor(() => expect(getListedEndpointNames()).toEqual(["Foo3", "Foo2", "Foo1"])); + expect(getColumnSortDirection("name")).toBe("descending"); + }); + + test("EXAMPLE: Endpoint instances are listed by host name ascending by default and can be sorted descending", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp( + precondition.hasHeartbeatsEndpoints([ + { ...healthyEndpointTemplate, id: "Endpoint1_HOST1", name: "Endpoint1", host_display_name: "HOST1" }, + { ...healthyEndpointTemplate, id: "Endpoint1_HOST2", name: "Endpoint1", host_display_name: "HOST2" }, + { ...healthyEndpointTemplate, id: "Endpoint1_HOST3", name: "Endpoint1", host_display_name: "HOST3" }, + ]) + ); + + await driver.goTo("heartbeats/instances/Endpoint1"); + + await waitFor(() => expect(getEndpointInstanceRows().map((row) => row.hostName)).toEqual(["HOST1", "HOST2", "HOST3"])); + expect(getColumnSortDirection("name")).toBe("ascending"); + + await sortEndpointsBy("name"); + + await waitFor(() => expect(getEndpointInstanceRows().map((row) => row.hostName)).toEqual(["HOST3", "HOST2", "HOST1"])); + expect(getColumnSortDirection("name")).toBe("descending"); + }); }); + describe("RULE: Filtering endpoints by name should be possible", () => { - test.todo("Not implemented"); + test("EXAMPLE: Filtering the active endpoints list shows only the endpoints whose name matches", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.endpointsWithHeartbeatStatus(["Foo1", "Bar", "Foo2"])); + + await driver.goTo("heartbeats/healthy"); + + await waitFor(() => expect(getListedEndpointNames()).toEqual(["Bar", "Foo1", "Foo2"])); + + await setHeartbeatFilter("Foo"); + + await waitFor(() => expect(getListedEndpointNames()).toEqual(["Foo1", "Foo2"])); + }); }); }); + describe("FEATURE: Inactive endpoints", () => { describe("RULE: The count of inactive endpoints should be displayed", () => { - test.todo("Not implemented"); + test("EXAMPLE: With 3 inactive endpoints, the tab should show (3)", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.endpointsWithHeartbeatStatus([], ["Down1", "Down2", "Down3"])); + + await driver.goTo("heartbeats/unhealthy"); + + await waitFor(() => expect(getHeartbeatsTabCount("inactive")).toBe(3)); + }); + + test("EXAMPLE: With 3 inactive endpoints and 1 endpoint starts sending heartbeats, the tab should show (2)", async ({ driver }) => { + vi.useFakeTimers(); + + try { + const recoveringEndpoint = "RecoveringEndpoint"; + const stillDown = ["Down1", "Down2"]; + + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.endpointsWithHeartbeatStatus([], [recoveringEndpoint, ...stillDown])); + + await driver.goTo("heartbeats/unhealthy"); + await waitFor(() => expect(getHeartbeatsTabCount("inactive")).toBe(3)); + + await driver.setUp(precondition.endpointsWithHeartbeatStatus([recoveringEndpoint], stillDown)); + vi.advanceTimersByTime(5000); + + await waitFor(() => expect(getHeartbeatsTabCount("inactive")).toBe(2)); + } finally { + vi.useRealTimers(); + } + }); }); + describe("RULE: Listing inactive endpoints should be possible", () => { - test.todo("Not implemented"); + test("EXAMPLE: With 3 inactive endpoints, 3 endpoints should be shown in the list of inactive endpoints", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.endpointsWithHeartbeatStatus([], ["Down1", "Down2", "Down3"])); + + await driver.goTo("heartbeats/unhealthy"); + + await waitFor(() => expect(getListedEndpointNames()).toEqual(["Down1", "Down2", "Down3"])); + }); }); + describe("RULE: Changing between logical and instance listing displays should be possible", () => { - test.todo("Not implemented"); + test("EXAMPLE: Opening a logical endpoint from the inactive list shows its instances, and going back returns to the inactive list", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp( + precondition.hasHeartbeatsEndpoints([ + { ...unHealthyEndpointTemplate, id: "EndpointDown_HOST1", name: "EndpointDown", host_display_name: "HOST1" }, + { ...unHealthyEndpointTemplate, id: "EndpointDown_HOST2", name: "EndpointDown", host_display_name: "HOST2" }, + ]) + ); + + await driver.goTo("heartbeats/unhealthy"); + + await waitFor(() => expect(getListedEndpointNames()).toEqual(["EndpointDown"])); + + await navigateToEndpointInstances("EndpointDown"); + expect(getEndpointInstanceRows().map((row) => row.hostName)).toEqual(["HOST1", "HOST2"]); + + await navigateBackToEndpointList("inactive"); + + await waitFor(() => expect(getListedEndpointNames()).toEqual(["EndpointDown"])); + }); }); + describe("RULE: Sorting by of the name of an endpoint should be possible in all displays", () => { - test.todo("Not implemented"); + test("EXAMPLE: Inactive endpoints are listed by name ascending by default and can be sorted descending", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.endpointsWithHeartbeatStatus([], ["Foo1", "Foo2", "Foo3"])); + + await driver.goTo("heartbeats/unhealthy"); + + await waitFor(() => expect(getListedEndpointNames()).toEqual(["Foo1", "Foo2", "Foo3"])); + expect(getColumnSortDirection("name")).toBe("ascending"); + + await sortEndpointsBy("name"); + + await waitFor(() => expect(getListedEndpointNames()).toEqual(["Foo3", "Foo2", "Foo1"])); + expect(getColumnSortDirection("name")).toBe("descending"); + }); + + test("EXAMPLE: Instances of an inactive endpoint are listed by host name ascending by default and can be sorted descending", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp( + precondition.hasHeartbeatsEndpoints([ + { ...unHealthyEndpointTemplate, id: "EndpointDown_HOST1", name: "EndpointDown", host_display_name: "HOST1" }, + { ...unHealthyEndpointTemplate, id: "EndpointDown_HOST2", name: "EndpointDown", host_display_name: "HOST2" }, + { ...unHealthyEndpointTemplate, id: "EndpointDown_HOST3", name: "EndpointDown", host_display_name: "HOST3" }, + ]) + ); + + await driver.goTo("heartbeats/instances/EndpointDown"); + + await waitFor(() => expect(getEndpointInstanceRows().map((row) => row.hostName)).toEqual(["HOST1", "HOST2", "HOST3"])); + expect(getColumnSortDirection("name")).toBe("ascending"); + + await sortEndpointsBy("name"); + + await waitFor(() => expect(getEndpointInstanceRows().map((row) => row.hostName)).toEqual(["HOST3", "HOST2", "HOST1"])); + expect(getColumnSortDirection("name")).toBe("descending"); + }); }); + describe("RULE: Filtering endpoints by name should be possible", () => { - test.todo("Not implemented"); + test("EXAMPLE: Filtering the inactive endpoints list shows only the endpoints whose name matches", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.endpointsWithHeartbeatStatus([], ["Foo1", "Bar", "Foo2"])); + + await driver.goTo("heartbeats/unhealthy"); + + await waitFor(() => expect(getListedEndpointNames()).toEqual(["Bar", "Foo1", "Foo2"])); + + await setHeartbeatFilter("Foo"); + + await waitFor(() => expect(getListedEndpointNames()).toEqual(["Foo1", "Foo2"])); + }); }); }); diff --git a/src/Frontend/test/specs/heartbeats/configuring-heartbeats.spec.ts b/src/Frontend/test/specs/heartbeats/configuring-heartbeats.spec.ts index 1a4a10e767..beb8749989 100644 --- a/src/Frontend/test/specs/heartbeats/configuring-heartbeats.spec.ts +++ b/src/Frontend/test/specs/heartbeats/configuring-heartbeats.spec.ts @@ -2,7 +2,7 @@ import { test, describe } from "../../drivers/vitest/driver"; import * as precondition from "../../preconditions"; import { expect, vi } from "vitest"; import { getNothingToConfigureStatus } from "./questions/getNothingToConfigureStatus"; -import { navigateToHeartbeatsConfiguration, navigateToUnHealthyHeartbeats } from "./actions/navigateToHeartbeatsTabs"; +import { navigateToHeartbeatsConfiguration, navigateToHealthyHeartbeats, navigateToUnHealthyHeartbeats } from "./actions/navigateToHeartbeatsTabs"; import { getEndpointsForConfiguration } from "./questions/getEndpointsForConfiguration"; import { getEndpointInstance } from "./questions/getEndpointInstance"; import { toggleHeartbeatMonitoring } from "./actions/toggleHeartbeatMonitoring"; @@ -10,7 +10,12 @@ import { getAllHeartbeatEndpointRecords, getHeartbeatEndpointRecord } from "./qu import { healthyEndpointTemplate } from "../../mocks/heartbeat-endpoint-template"; import { setHeartbeatFilter } from "./actions/setHeartbeatFilter"; import { getHeartbeatFilterValue } from "./questions/getHeartbeatFilterValue"; +import { getListedEndpointNames } from "./questions/getListedEndpointNames"; +import { getColumnSortDirection } from "./questions/getColumnSortDirection"; +import { sortEndpointsBy } from "./actions/sortEndpointsBy"; +import { waitFor } from "@testing-library/vue"; import { flushPromises } from "@vue/test-utils"; +import { EndpointStatus } from "@/resources/Heartbeat"; vi.mock("@vueuse/core", async (importOriginal) => { const originalModule = await importOriginal(); @@ -21,6 +26,20 @@ vi.mock("@vueuse/core", async (importOriginal) => { }; }); +const endpointsStagedByHeartbeatAge = () => [endpointWithHeartbeatAge("Foo1", 2), endpointWithHeartbeatAge("Foo2", 3), endpointWithHeartbeatAge("Foo3", 1)]; + +function endpointWithHeartbeatAge(name: string, hoursSinceHeartbeat: number) { + return { + ...healthyEndpointTemplate, + id: name, + name, + heartbeat_information: { + reported_status: EndpointStatus.Alive, + last_report_at: new Date(Date.now() - hoursSinceHeartbeat * 60 * 60 * 1000).toISOString(), + }, + }; +} + describe("FEATURE: Heartbeats configuration", () => { describe("RULE: A list of all endpoints with the heartbeats plug-in installed should be displayed", () => { test("EXAMPLE: With no endpoints, the text 'Nothing to configure' should be displayed", async ({ driver }) => { @@ -133,47 +152,78 @@ describe("FEATURE: Heartbeats configuration", () => { }); describe("RULE: Sorting by of the name of an endpoint should be possible in all displays", () => { - test.todo("EXAMPLE: List of endpoints should be sorted by name in ascending order"); + test("EXAMPLE: List of endpoints should be sorted by name in ascending order", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasHeartbeatsEndpoints(endpointsStagedByHeartbeatAge())); + await driver.goTo("heartbeats/unhealthy"); - /* SCENARIO - Given 3 endpoint instance - Name | - Foo1 - Foo2 - Foo3 - When the sort by is set to Name - Then the instances should be listed in order - */ + await navigateToHeartbeatsConfiguration(); - /* NOTES - Name (asc/desc) - Latest heartbeat (asc/dec) - */ + await waitFor(() => expect(getListedEndpointNames()).toEqual(["Foo1", "Foo2", "Foo3"])); + expect(getColumnSortDirection("name")).toBe("ascending"); + }); - test.todo("EXAMPLE: List of endpoints should be sorted by name in descending order"); - /* SCENARIO - Given 3 endpoint instance - Name | - Foo1 - Foo2 - Foo3 - When the sort by is set to Name (descending) - Then the instances should be listed in reverse order - */ + test("EXAMPLE: List of endpoints should be sorted by name in descending order", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasHeartbeatsEndpoints(endpointsStagedByHeartbeatAge())); + await driver.goTo("heartbeats/unhealthy"); - test.todo("EXAMPLE: List of endpoints should be sorted by latest heartbeat in ascending order"); - test.todo("EXAMPLE: List of endpoints should be sorted by latest heartbeat in descending order"); - /* SCENARIO - Same again for Latest heartbeat - */ + await navigateToHeartbeatsConfiguration(); + await waitFor(() => expect(getListedEndpointNames()).toEqual(["Foo1", "Foo2", "Foo3"])); - test.todo("EXAMPLE: Sort by should be persisted on page refresh and across tabs"); - /* SCENARIO - Given the Sort By field has been changed - When the page is refreshed - Then the Sort By field retains its value - And the Sort By field has the same value on all other Endpoint Heartbeats tabs - */ + await sortEndpointsBy("name"); + + await waitFor(() => expect(getListedEndpointNames()).toEqual(["Foo3", "Foo2", "Foo1"])); + expect(getColumnSortDirection("name")).toBe("descending"); + }); + + test("EXAMPLE: List of endpoints should be sorted by latest heartbeat in descending order", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasHeartbeatsEndpoints(endpointsStagedByHeartbeatAge())); + await driver.goTo("heartbeats/unhealthy"); + + await navigateToHeartbeatsConfiguration(); + await waitFor(() => expect(getListedEndpointNames()).toEqual(["Foo1", "Foo2", "Foo3"])); + + await sortEndpointsBy("latestHeartbeat"); + + await waitFor(() => expect(getListedEndpointNames()).toEqual(["Foo3", "Foo1", "Foo2"])); + expect(getColumnSortDirection("latestHeartbeat")).toBe("descending"); + }); + + test("EXAMPLE: List of endpoints should be sorted by latest heartbeat in ascending order", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasHeartbeatsEndpoints(endpointsStagedByHeartbeatAge())); + await driver.goTo("heartbeats/unhealthy"); + + await navigateToHeartbeatsConfiguration(); + await waitFor(() => expect(getListedEndpointNames()).toEqual(["Foo1", "Foo2", "Foo3"])); + + await sortEndpointsBy("latestHeartbeat"); // newest first + await sortEndpointsBy("latestHeartbeat"); // oldest first + + await waitFor(() => expect(getListedEndpointNames()).toEqual(["Foo2", "Foo1", "Foo3"])); + expect(getColumnSortDirection("latestHeartbeat")).toBe("ascending"); + }); + + test("EXAMPLE: Sort by is shared across the Endpoint Heartbeats tabs", async ({ driver }) => { + await driver.setUp(precondition.serviceControlWithMonitoring); + await driver.setUp(precondition.hasHeartbeatsEndpoints(endpointsStagedByHeartbeatAge())); + await driver.goTo("heartbeats/unhealthy"); + + await navigateToHeartbeatsConfiguration(); + await waitFor(() => expect(getListedEndpointNames()).toEqual(["Foo1", "Foo2", "Foo3"])); + + await sortEndpointsBy("latestHeartbeat"); + await waitFor(() => expect(getListedEndpointNames()).toEqual(["Foo3", "Foo1", "Foo2"])); + + await navigateToHealthyHeartbeats(); + + await waitFor(() => expect(getListedEndpointNames()).toEqual(["Foo3", "Foo1", "Foo2"])); + expect(getColumnSortDirection("latestHeartbeat")).toBe("descending"); + }); + + test.todo("EXAMPLE: Sort by should be persisted on page refresh"); }); describe("RULE: Filtering endpoints by name should be possible", () => { @@ -265,6 +315,11 @@ describe("FEATURE: Heartbeats configuration", () => { /* SCENARIO When the configuration screen is loaded Then a warning should be displayed about this being disconnected to performance monitoring + + NOTES + The configuration screen shows no such warning. The only advisory on the + heartbeats pages is the MassTransit compatibility notice, which is unrelated + to performance monitoring, so there is nothing to assert against yet. */ }); }); diff --git a/src/Frontend/test/specs/heartbeats/questions/getColumnSortDirection.ts b/src/Frontend/test/specs/heartbeats/questions/getColumnSortDirection.ts new file mode 100644 index 0000000000..be394edf36 --- /dev/null +++ b/src/Frontend/test/specs/heartbeats/questions/getColumnSortDirection.ts @@ -0,0 +1,21 @@ +import { screen, within } from "@testing-library/vue"; + +export type ColumnSortDirection = "ascending" | "descending" | "unsorted"; + +export function getColumnSortDirection(columnName: string): ColumnSortDirection { + const columnHeader = screen.queryByRole("columnheader", { name: columnName }); + + if (!columnHeader) { + return "unsorted"; + } + + if (within(columnHeader).queryByRole("img", { name: "sort-up" })) { + return "ascending"; + } + + if (within(columnHeader).queryByRole("img", { name: "sort-down" })) { + return "descending"; + } + + return "unsorted"; +} diff --git a/src/Frontend/test/specs/heartbeats/questions/getEndpointInstanceRows.ts b/src/Frontend/test/specs/heartbeats/questions/getEndpointInstanceRows.ts new file mode 100644 index 0000000000..e8c1493be9 --- /dev/null +++ b/src/Frontend/test/specs/heartbeats/questions/getEndpointInstanceRows.ts @@ -0,0 +1,21 @@ +import { screen, within } from "@testing-library/vue"; + +export type EndpointInstanceRow = { + hostName: string; + lastHeartbeat: string; +}; + +export function getEndpointInstanceRows(): EndpointInstanceRow[] { + const instanceRowgroup = screen.queryByRole("rowgroup", { name: "endpoints" }); + + if (!instanceRowgroup) { + return []; + } + + return within(instanceRowgroup) + .queryAllByRole("row") + .map((row) => ({ + hostName: within(row).getByLabelText("instance-name").textContent ?? "", + lastHeartbeat: within(row).getByTitle("Last Heartbeat").textContent ?? "", + })); +} diff --git a/src/Frontend/test/specs/heartbeats/questions/getHeartbeatsTabCount.ts b/src/Frontend/test/specs/heartbeats/questions/getHeartbeatsTabCount.ts new file mode 100644 index 0000000000..3c29fd9d89 --- /dev/null +++ b/src/Frontend/test/specs/heartbeats/questions/getHeartbeatsTabCount.ts @@ -0,0 +1,15 @@ +import { screen } from "@testing-library/vue"; + +export type HeartbeatsTab = "active" | "inactive"; + +const tabAccessibleName: Record = { + active: /^\s*Healthy Endpoints \(\d+\)\s*$/i, + inactive: /^\s*Unhealthy Endpoints \(\d+\)\s*$/i, +}; + +export function getHeartbeatsTabCount(tab: HeartbeatsTab): number | null { + const tabElement = screen.queryAllByRole("tab").find((element) => tabAccessibleName[tab].test(element.textContent ?? "")); + const [, count] = tabElement?.textContent?.match(/\((\d+)\)/) ?? []; + + return count === undefined ? null : Number(count); +} diff --git a/src/Frontend/test/specs/heartbeats/questions/getListedEndpointNames.ts b/src/Frontend/test/specs/heartbeats/questions/getListedEndpointNames.ts new file mode 100644 index 0000000000..7310d5ac08 --- /dev/null +++ b/src/Frontend/test/specs/heartbeats/questions/getListedEndpointNames.ts @@ -0,0 +1,13 @@ +import { screen, within } from "@testing-library/vue"; + +export function getListedEndpointNames(): string[] { + const endpointRowgroup = screen.queryByRole("rowgroup", { name: "endpoints" }); + + if (!endpointRowgroup) { + return []; + } + + return within(endpointRowgroup) + .queryAllByRole("row") + .map((row) => within(row).getByRole("link", { name: "details-link" }).textContent ?? ""); +}