diff --git a/integration_tests/pages/BasePage.ts b/integration_tests/pages/BasePage.ts index 7485ec4..51441c8 100644 --- a/integration_tests/pages/BasePage.ts +++ b/integration_tests/pages/BasePage.ts @@ -1,13 +1,16 @@ import { Page } from "@playwright/test"; +import { Header } from "./Header"; import { Modal } from "./Modal"; import { Notification } from "./Notification"; export abstract class BasePage { + header: Header; modal: Modal; notification: Notification; constructor(public page: Page) { this.page = page; + this.header = new Header(page); this.modal = new Modal(page); this.notification = new Notification(page); } diff --git a/integration_tests/pages/Header.ts b/integration_tests/pages/Header.ts new file mode 100644 index 0000000..ead70af --- /dev/null +++ b/integration_tests/pages/Header.ts @@ -0,0 +1,24 @@ +import { Page } from "@playwright/test"; +import { + HEADER_HELP_MENU_ID, + HEADER_HELP_MENU_BUTTON_TEST_ID, +} from "~client/components/Header.locators"; +import { TAKE_TOUR_BUTTON_TEST_ID } from "~client/components/GuidedTour.locators"; +import { TourModal } from "./TourModal"; + +export class Header { + helpMenuButton = this.page.getByTestId(HEADER_HELP_MENU_BUTTON_TEST_ID); + helpMenu = this.page.getByTestId(HEADER_HELP_MENU_ID); + takeTourButton = this.helpMenu.getByTestId(TAKE_TOUR_BUTTON_TEST_ID); + + constructor(public page: Page) { + this.page = page; + } + + async startTour(): Promise { + await this.helpMenuButton.click(); + await this.takeTourButton.click(); + + return new TourModal(this.page); + } +} diff --git a/integration_tests/pages/LoginPage.ts b/integration_tests/pages/LoginPage.ts index ab46269..a15e915 100644 --- a/integration_tests/pages/LoginPage.ts +++ b/integration_tests/pages/LoginPage.ts @@ -1,4 +1,5 @@ import { BasePage } from "./BasePage"; +import { LOCATOR_LOGIN_FORM } from "~client/constants/help"; export class LoginPage extends BasePage { email = this.page.getByTestId("email"); @@ -7,4 +8,5 @@ export class LoginPage extends BasePage { showPasswordBtn = this.page.locator( "[aria-label='toggle password visibility']", ); + loginForm = this.page.locator(`#${LOCATOR_LOGIN_FORM}`); } diff --git a/integration_tests/pages/ProjectPage.ts b/integration_tests/pages/ProjectPage.ts index 5f3a9d4..04bc68a 100644 --- a/integration_tests/pages/ProjectPage.ts +++ b/integration_tests/pages/ProjectPage.ts @@ -2,10 +2,12 @@ import { Page } from "@playwright/test"; import { BasePage } from "./BasePage"; import { TestRunList } from "./components/TestRunList"; import { BuildList } from "./components/BuildList"; +import { LOCATOR_PROJECT_PAGE_SELECT_PROJECT } from "~client/constants/help"; export class ProjectPage extends BasePage { testRunList: TestRunList; buildList: BuildList; + selectProject = this.page.locator(`#${LOCATOR_PROJECT_PAGE_SELECT_PROJECT}`); constructor(page: Page) { super(page); diff --git a/integration_tests/pages/TestVariationListPage.ts b/integration_tests/pages/TestVariationListPage.ts index 32968cc..1db9812 100644 --- a/integration_tests/pages/TestVariationListPage.ts +++ b/integration_tests/pages/TestVariationListPage.ts @@ -1,3 +1,6 @@ import { BasePage } from "./BasePage"; +import { LOCATOR_TEST_VARIATION_LIST_PAGE_SELECT_PROJECT } from "~client/constants/help"; -export class TestVariationListPage extends BasePage {} +export class TestVariationListPage extends BasePage { + selectProject = this.page.locator(`#${LOCATOR_TEST_VARIATION_LIST_PAGE_SELECT_PROJECT}`); +} diff --git a/integration_tests/pages/TourModal.ts b/integration_tests/pages/TourModal.ts new file mode 100644 index 0000000..1bce1d8 --- /dev/null +++ b/integration_tests/pages/TourModal.ts @@ -0,0 +1,34 @@ +import { Page } from "@playwright/test"; + +export class TourModal { + joyrideOverlay = this.page.locator('.react-joyride__tooltip'); + skipButton = this.joyrideOverlay.getByRole("button", { name: /skip/i }); + nextButton = this.joyrideOverlay.getByRole("button", { name: /next/i }); + lastButton = this.joyrideOverlay.getByRole("button", { name: /last/i }); + backButton = this.joyrideOverlay.getByRole("button", { name: /back/i }); + closeButton = this.joyrideOverlay.getByRole("button", { name: /close/i }); + + constructor(public page: Page) { + this.page = page; + } + + async skipTour() { + await this.skipButton.click(); + } + + async clickNext() { + await this.nextButton.click(); + } + + async clickLast() { + await this.lastButton.click(); + } + + async clickBack() { + await this.backButton.click(); + } + + async clickClose() { + await this.closeButton.click(); + } +} diff --git a/integration_tests/pages/index.ts b/integration_tests/pages/index.ts index a73a784..e96b2e9 100644 --- a/integration_tests/pages/index.ts +++ b/integration_tests/pages/index.ts @@ -1,3 +1,4 @@ +export * from "./Header"; export * from "./LoginPage"; export * from "./ProfilePage"; export * from "./ProjectListPage"; diff --git a/integration_tests/test/guidedTour.spec.ts b/integration_tests/test/guidedTour.spec.ts new file mode 100644 index 0000000..2ce8b7f --- /dev/null +++ b/integration_tests/test/guidedTour.spec.ts @@ -0,0 +1,137 @@ +import { expect } from "@playwright/test"; +import { test } from "fixtures"; +import { + TEST_BUILD_FAILED, + TEST_BUILD_PASSED, + TEST_BUILD_UNRESOLVED, + TEST_PROJECT, + TEST_RUN_APPROVED, + TEST_RUN_NEW, + TEST_RUN_OK, + TEST_UNRESOLVED, + TEST_VARIATION_ONE, + TEST_VARIATION_TWO, +} from "~client/_test/test.data.helper"; +import { + LOGIN_PAGE_STEPS, + PROJECT_LIST_PAGE_STEPS, + PROJECT_PAGE_STEPS, + TEST_VARIATION_LIST_PAGE, +} from "~client/constants/help"; +import { + mockGetBuildDetails, + mockGetBuilds, + mockGetProjects, + mockGetTestRuns, + mockGetTestVariations, + mockImage, + mockTestRun, +} from "utils/mocks"; + +const project = TEST_PROJECT; + +test.beforeEach(async ({ page }) => { + await mockGetProjects(page, [project]); + await mockGetBuilds(page, project.id, [ + TEST_BUILD_FAILED, + TEST_BUILD_PASSED, + TEST_BUILD_UNRESOLVED, + ]); + await mockGetBuildDetails(page, TEST_BUILD_FAILED); + await mockGetTestRuns(page, TEST_BUILD_FAILED.id, [ + TEST_UNRESOLVED, + TEST_RUN_APPROVED, + TEST_RUN_NEW, + TEST_RUN_OK, + ]); + await mockTestRun(page, TEST_UNRESOLVED); + await mockGetTestVariations(page, project.id, [ + TEST_VARIATION_ONE, + TEST_VARIATION_TWO, + ]); + await mockImage(page, "baseline.png"); + await mockImage(page, "diff.png"); + await mockImage(page, "image.png"); + await mockImage(page, "baseline1.png"); + await mockImage(page, "baseline2.png"); +}); + +test.describe("Guided Tour", () => { + test("should allow skipping the tour", async ({ loginPage }) => { + const tour = await loginPage.header.startTour(); + await expect(tour.joyrideOverlay).toBeVisible(); + + await tour.skipTour(); + + await expect(tour.joyrideOverlay).not.toBeVisible(); + }); + + test.describe("Login Page", () => { + test("should display all tour steps", async ({ loginPage }) => { + const tour = await loginPage.header.startTour(); + + await expect(tour.joyrideOverlay).toContainText(LOGIN_PAGE_STEPS[0].content as string); + await tour.clickNext(); + + await expect(tour.joyrideOverlay).toContainText(LOGIN_PAGE_STEPS[1].content as string); + await tour.clickLast(); + + await expect(tour.joyrideOverlay).not.toBeVisible(); + }); + }); + + test.describe("Project List Page", () => { + test("should display all tour steps", async ({ projectListPage }) => { + const tour = await projectListPage.header.startTour(); + + await expect(tour.joyrideOverlay).toContainText(PROJECT_LIST_PAGE_STEPS[0].title as string); + await tour.clickLast(); + + await expect(tour.joyrideOverlay).not.toBeVisible(); + }); + }); + + test.describe("Project Page", () => { + test("should display all tour steps", async ({ openProjectPage }) => { + const projectPage = await openProjectPage(project.id); + await projectPage.buildList.getBuildLocator(TEST_BUILD_FAILED.number).click(); + + const tour = await projectPage.header.startTour(); + + await expect(tour.joyrideOverlay).toContainText(PROJECT_PAGE_STEPS[0].content as string); + await tour.clickNext(); + + await expect(tour.joyrideOverlay).toContainText(PROJECT_PAGE_STEPS[1].content as string); + await tour.clickNext(); + + await expect(tour.joyrideOverlay).toContainText(PROJECT_PAGE_STEPS[2].content as string); + await tour.clickNext(); + + await expect(tour.joyrideOverlay).toContainText(PROJECT_PAGE_STEPS[3].content as string); + await tour.clickNext(); + + await expect(tour.joyrideOverlay).toContainText(PROJECT_PAGE_STEPS[4].content as string); + await tour.clickLast(); + + await expect(tour.joyrideOverlay).not.toBeVisible(); + }); + }); + + test.describe("Test Variation List Page", () => { + test("should display all tour steps", async ({ openTestVariationListPage }) => { + const variationListPage = await openTestVariationListPage(project.id); + const tour = await variationListPage.header.startTour(); + + await expect(tour.joyrideOverlay).toContainText(TEST_VARIATION_LIST_PAGE[0].title); + await tour.clickNext(); + + await expect(tour.joyrideOverlay).toContainText(TEST_VARIATION_LIST_PAGE[1].content); + await tour.clickNext(); + + await expect(tour.joyrideOverlay).toContainText(TEST_VARIATION_LIST_PAGE[2].content); + await tour.clickLast(); + + await expect(tour.joyrideOverlay).not.toBeVisible(); + }); + }); +}); diff --git a/src/components/GuidedTour.locators.ts b/src/components/GuidedTour.locators.ts new file mode 100644 index 0000000..14df7e9 --- /dev/null +++ b/src/components/GuidedTour.locators.ts @@ -0,0 +1 @@ +export const TAKE_TOUR_BUTTON_TEST_ID = "takeTourButton"; diff --git a/src/components/GuidedTour.tsx b/src/components/GuidedTour.tsx index 818117c..27dae2a 100644 --- a/src/components/GuidedTour.tsx +++ b/src/components/GuidedTour.tsx @@ -3,71 +3,74 @@ import Joyride, { CallBackProps, STATUS } from "react-joyride"; import { Button } from "@mui/material"; import { useHelpState } from "../contexts"; import { LiveHelp } from "@mui/icons-material"; +import { TAKE_TOUR_BUTTON_TEST_ID } from "./GuidedTour.locators"; const GuidedTour: FunctionComponent = () => { const [run, setRun] = React.useState(false); const { helpSteps } = useHelpState(); const getHelpSteps = React.useCallback(() => { - const [firstStep] = helpSteps; - - //Below line is to prevent application breaking if element is not present for any reason (e.g. if the user deletes build or if there is no data.) - if ( - firstStep && - document.getElementById(firstStep.target.toString().slice(1)) - ) { - for (const step of helpSteps) { - step.disableBeacon = true; - step.hideCloseButton = true; - } + if (!helpSteps?.length) { + return []; + } - return helpSteps; + for (const step of helpSteps) { + step.disableBeacon = true; + step.hideCloseButton = true; } - return []; + return helpSteps; }, [helpSteps]); const handleJoyrideCallback = ({ status }: CallBackProps) => { - const finishedStatuses: string[] = [STATUS.FINISHED, STATUS.SKIPPED]; - - if (finishedStatuses.includes(status)) { + if (status === STATUS.FINISHED || status === STATUS.SKIPPED) { setRun(false); } }; const handleClickStart = (event: React.MouseEvent) => { event.preventDefault(); + + const [firstStep] = helpSteps; + if (firstStep && typeof firstStep.target === 'string') { + const targetId = firstStep.target.startsWith('#') + ? firstStep.target.slice(1) + : firstStep.target; + + if (!document.getElementById(targetId)) { + return; + } + } + setRun(true); }; return ( - - - + ); }; diff --git a/src/components/Header.locators.ts b/src/components/Header.locators.ts new file mode 100644 index 0000000..5cfa316 --- /dev/null +++ b/src/components/Header.locators.ts @@ -0,0 +1,4 @@ +export const HEADER_HELP_MENU_ID = "headerHelpMenu"; +export const HEADER_AVATAR_MENU_ID = "headerAvatarMenu"; +export const HEADER_HELP_MENU_BUTTON_TEST_ID = "helpMenuButton"; +export const HEADER_LOGOUT_BUTTON_TEST_ID = "logoutBtn"; diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 77fecea..d7347e6 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -13,6 +13,12 @@ import { useUserDispatch, useUserState, logout } from "../contexts"; import { routes } from "../constants"; import logo from "../static/logo.png"; import GuidedTour from "./GuidedTour"; +import { + HEADER_HELP_MENU_ID, + HEADER_AVATAR_MENU_ID, + HEADER_HELP_MENU_BUTTON_TEST_ID, + HEADER_LOGOUT_BUTTON_TEST_ID, +} from "./Header.locators"; import { AllInbox, Face, @@ -63,7 +69,7 @@ const Header: FunctionComponent = () => { vertical: "top", horizontal: "right", }} - id="headerHelpMenu" + data-testid={HEADER_HELP_MENU_ID} keepMounted transformOrigin={{ vertical: "top", @@ -100,7 +106,7 @@ const Header: FunctionComponent = () => { vertical: "top", horizontal: "right", }} - id="headerAvatarMenu" + id={HEADER_AVATAR_MENU_ID} keepMounted transformOrigin={{ vertical: "top", @@ -149,7 +155,7 @@ const Header: FunctionComponent = () => { handleMenuClose(); logout(authDispatch); }} - data-testid="logoutBtn" + data-testid={HEADER_LOGOUT_BUTTON_TEST_ID} > @@ -181,6 +187,7 @@ const Header: FunctionComponent = () => { } size="large" color="secondary" + data-testid={HEADER_HELP_MENU_BUTTON_TEST_ID} > diff --git a/src/constants/index.ts b/src/constants/index.ts index 1d59c57..5f2b7f9 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1,3 +1,3 @@ -export * from "./routes"; -export * from "./project"; export * from "./help"; +export * from "./project"; +export * from "./routes"; diff --git a/vite.config.ts b/vite.config.ts index 02d3dfd..0c77a26 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,12 @@ import react from "@vitejs/plugin-react"; // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], + define: { + // Polyfill for Node.js 'global' object in browser environment + // Required for react-joyride and other libraries that expect Node.js globals + // This replaces all instances of 'global' with 'globalThis' at build time + global: "globalThis", + }, server: { fs: { // Allow using "npm link" for packages when developing, that are in different base path