diff --git a/e2e/.env-template b/e2e/.env-template new file mode 100644 index 000000000..645d95f36 --- /dev/null +++ b/e2e/.env-template @@ -0,0 +1,7 @@ +BASE_URL= +BCSC_USERNAME= +BCSC_PASSWORD= +BCEID_USERNAME= +BCEID_PASSWORD= +IDIR_USERNAME= +IDIR_PASSWORD= diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 000000000..a86a3040c --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +*.env diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 000000000..cc1222cb1 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,21 @@ +# Playwright End-to-End Testing + +End-to-end (e2e) testing involves testing the entire stack of the application, including the frontend (react-app), API (express-api), and database. + +These tests should focus on user paths and should mimic how the user navigates the site. + +## Setup + +1. Run `npm i` to install dependencies. +2. Run `npx playwright install` to install Playwright browsers. +3. Create and populate a `.env` file based on the `.env-template` file. + +For the `BASE_URL`, start with `localhost:` for local testing. + +Change BASE_URL to target other environments as needed. + +## Run Tests + +- `npx playwright test`: Runs tests in headless mode. +- `npx playwright test --ui`: Starts the Playwright UI. +- `npx playwright codegen `: Starts the Codegen test maker. You can use this to capture movements in a browser and construct tests. diff --git a/e2e/env.ts b/e2e/env.ts new file mode 100644 index 000000000..a4adb4eaf --- /dev/null +++ b/e2e/env.ts @@ -0,0 +1,11 @@ +import dotenv from 'dotenv'; + +dotenv.config() + +export const BASE_URL = process.env.BASE_URL ?? 'localhost:3000'; +export const BCSC_USERNAME = process.env.BCSC_USERNAME ?? ''; +export const BCSC_PASSWORD = process.env.BCSC_PASSWORD ?? ''; +export const BCEID_USERNAME = process.env.BCEID_USERNAME ?? ''; +export const BCEID_PASSWORD = process.env.BCEID_PASSWORD ?? ''; +export const IDIR_USERNAME = process.env.IDIR_USERNAME ?? ''; +export const IDIR_PASSWORD = process.env.IDIR_PASSWORD ?? ''; diff --git a/e2e/functions/login.ts b/e2e/functions/login.ts new file mode 100644 index 000000000..2fcae2dec --- /dev/null +++ b/e2e/functions/login.ts @@ -0,0 +1,46 @@ +/** + * Functions for automating user logins. + */ + +import { Page } from '@playwright/test'; +import {BASE_URL, BCEID_PASSWORD, BCEID_USERNAME, BCSC_PASSWORD, BCSC_USERNAME, IDIR_PASSWORD, IDIR_USERNAME} from '../env'; + +// Will only work in DEV/TEST environments +export const loginBCServicesCard = async (page: Page) => { + await page.goto(BASE_URL); + await page.getByRole('button', { name: 'Login' }).click(); + await page.waitForLoadState('networkidle'); + await page.getByRole('link', { name: 'BC Services Card' }).click(); + await page.waitForLoadState('networkidle'); + await page.getByLabel('Log in with Test with').click(); + await page.waitForLoadState('networkidle'); + await page.getByLabel('Email or username').click(); + await page.getByLabel('Email or username').fill(BCSC_USERNAME); + await page.getByLabel('Password').click(); + await page.getByLabel('Password').fill(BCSC_PASSWORD); + await page.getByRole('button', { name: 'Continue' }).click(); +}; + +export const loginBCeID = async (page: Page) => { + await page.goto(BASE_URL); + await page.getByRole('button', { name: 'Login' }).click(); + await page.waitForLoadState('networkidle'); + await page.getByRole('link', { name: 'Basic or Business BCeID' }).click(); + await page.waitForLoadState('networkidle'); + await page.locator('#user').fill(BCEID_USERNAME); + await page.getByLabel('Password').click(); + await page.getByLabel('Password').fill(BCEID_PASSWORD); + await page.getByRole('button', { name: 'Continue' }).click(); +}; + +export const loginIDIR = async (page: Page) => { + await page.goto(BASE_URL); + await page.getByRole('button', { name: 'Login' }).click(); + await page.waitForLoadState('networkidle'); + await page.getByRole('link', { name: 'IDIR' }).click(); + await page.waitForLoadState('networkidle'); + await page.locator('#user').fill(IDIR_USERNAME); + await page.getByLabel('Password').click(); + await page.getByLabel('Password').fill(IDIR_PASSWORD); + await page.getByRole('button', { name: 'Continue' }).click(); +}; diff --git a/e2e/functions/mockRequests.ts b/e2e/functions/mockRequests.ts new file mode 100644 index 000000000..47102cb96 --- /dev/null +++ b/e2e/functions/mockRequests.ts @@ -0,0 +1,43 @@ +import { Page } from "@playwright/test"; + +interface User { + CreatedById: string; + CreatedOn: string; + UpdatedById: string; + UpdatedOn: string; + Id: string; + Username: string; + FirstName: string; + MiddleName: string | null; + LastName: string; + Email: string; + Position: string; + Note: string | null; + LastLogin: string; + ApprovedById: string; + ApprovedOn: string; + KeycloakUserId: string; + AgencyId: number; + RoleId: string | undefined; + Status: string; +} + +/** + * Use this at the start of a test to set your user information if needed. + * Otherwise, your user information will reflect the actual user credentials used in testing. + * @param page Page of e2e test. + * @param self A partial object with user attributes. + * @param status An optional status number. Defaults to 200. + */ +export const mockSelf = async (page: Page, self?: Partial, status: number = 200) => { + // Make the actual call, but then edit it with any partial user passed in before it gets back to the page + await page.route('**/users/self', async route => { + const response = await route.fetch(); + let json = await response.json(); + json = { + ...json, + ...self + } + await route.fulfill({ response, json }); + }) +}; diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 000000000..912a7940b --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,17 @@ +{ + "name": "e2e", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": {}, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@playwright/test": "1.48.0", + "@types/node": "22.7.5" + }, + "dependencies": { + "dotenv": "16.4.5" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 000000000..7c6c3cfec --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,80 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// import path from 'path'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + expect: { + timeout: 10000, + }, + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + // Webkit not compatible with Keycloak package + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/e2e/tests/agencies.spec.ts b/e2e/tests/agencies.spec.ts new file mode 100644 index 000000000..a5fa2e580 --- /dev/null +++ b/e2e/tests/agencies.spec.ts @@ -0,0 +1,70 @@ +/** + * Testing navigating to Agency Table and using some filter functions. + * There are some assumptions that you will have some standard agencies populated. + */ + +import { test, expect, Page } from '@playwright/test' +import { BASE_URL } from '../env'; +import { loginIDIR } from '../functions/login'; +import { mockSelf } from '../functions/mockRequests'; + +const getToAgencies = async (page: Page) => { + await page.goto(BASE_URL); + await mockSelf(page, { RoleId: "00000000-0000-0000-0000-000000000000" }); // guarantee admin status + await loginIDIR(page); + await page.getByRole('heading', { name: 'Administration' }).waitFor(); + await page.getByRole('heading', { name: 'Administration' }).click(); + await page.getByRole('menuitem', { name: 'Agencies' }).click(); + await page.getByText('Agencies Overview'); +} + +test('user can navigate to agencies page as an admin', async ({ page }) => { + await getToAgencies(page); + await expect(page.getByText('Agencies Overview')).toBeVisible(); +}) + +test('user can filter using the keyword search', async ({ page }) => { + await getToAgencies(page); + + await page.getByTestId('SearchIcon').click(); + await page.getByPlaceholder('Search...').click(); + await page.getByPlaceholder('Search...').fill('real'); + await expect(page.getByRole('gridcell', { name: 'Real Property Division' })).toBeVisible(); + // Clear filter and make sure it is empty + await page.getByLabel('Clear Filter').click(); + await expect(page.getByPlaceholder('Search...')).toBeEmpty(); + await expect(page.getByLabel('Clear Filter')).not.toBeVisible(); +}) + +test('user can filter using the select dropdown', async ({ page }) => { + await getToAgencies(page); + + await page.getByText('All Agencies').click(); + await page.getByRole('option', { name: 'Disabled', exact: true }).click(); + // Not clear what to look for here to guarantee that only Disabled agencies show + await expect(page.getByTestId('FilterAltIcon')).toBeVisible(); // The icon in the column header + await expect(page.getByLabel('Clear Filter')).toBeVisible(); + await page.getByLabel('Clear Filter').click(); + await expect(page.getByLabel('Clear Filter')).not.toBeVisible(); +}) + +test('user can filter using the column filter', async ({ page }) => { + await getToAgencies(page); + + await page.getByText('Name', { exact: true }).hover(); + await page.getByRole('button', { name: 'Menu' }).click(); + await page.getByRole('menuitem', { name: 'Filter' }).click(); + await page.getByPlaceholder('Filter value').click(); + await page.getByPlaceholder('Filter value').fill('real'); + await page.getByText('Agencies Overview (1 rows)All').click(); // To get filter off screen + await expect(page.getByRole('gridcell', { name: 'Real Property Division' })).toBeVisible(); +}) + +test('user select a row and go to that agency', async ({ page }) => { + await getToAgencies(page); + + await page.getByRole('gridcell', { name: 'Advanced Education & Skills' }).first().click(); + // These two expects together should confirm user is on correct details page + await expect(page.getByText('Agency Details')).toBeVisible(); + await expect(page.getByText('Advanced Education & Skills')).toBeVisible(); +}) diff --git a/e2e/tests/login.spec.ts b/e2e/tests/login.spec.ts new file mode 100644 index 000000000..d631206db --- /dev/null +++ b/e2e/tests/login.spec.ts @@ -0,0 +1,22 @@ +/** + * Testing login options. + */ + +import { test, expect } from '@playwright/test'; +import { loginBCServicesCard, loginBCeID, loginIDIR } from '../functions/login'; + +test('log in with BC Services Card', async ({ page }) => { + await loginBCServicesCard(page); + await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible(); +}); + +test('log in with BCeID', async ({ page }) => { + await loginBCeID(page); + await page.getByRole('button', { name: 'Logout' }).waitFor(); + await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible(); +}); + +test('log in with IDIR', async ({ page }) => { + await loginIDIR(page); + await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible(); +}); diff --git a/e2e/tests/map.spec.ts b/e2e/tests/map.spec.ts new file mode 100644 index 000000000..eef820aeb --- /dev/null +++ b/e2e/tests/map.spec.ts @@ -0,0 +1,147 @@ +import { test, expect, Page } from "@playwright/test"; +import { BASE_URL } from "../env"; +import { loginIDIR } from "../functions/login"; + +test('user can view all parts of map', async ({ page }) => { + await page.goto(BASE_URL); + await loginIDIR(page); + // Leaflet map is visible + await page.waitForLoadState(); + await expect(page.locator('#parcel-map')).toBeVisible(); + // Controls on map are visible + await expect(page.getByRole('link', { name: 'Draw a polygon' })).toBeVisible(); + await expect(page.getByLabel('Zoom in')).toBeVisible(); + // At least one property is rendered as a cluster + await expect(page.locator('.cluster-marker').first()).toBeVisible(); +}) + +test('sidebar controls work as expected (not filter)', async ({ page }) => { + await page.goto(BASE_URL); + await loginIDIR(page); + // Leaflet map is visible + await page.waitForLoadState(); + await expect(page.locator('#parcel-map')).toBeVisible(); + // Sidebar is visible + await expect(page.locator('#map-sidebar')).toBeVisible(); + // Sidebar has properties (also ensures that properties were loaded first) + await expect(page.locator('.property-row').first()).toBeVisible(); + + // Can click sidebar navigation and it affects value + await expect(page.locator('#sidebar-count')).toContainText('1 of'); + // Up + await page.locator('#sidebar-increment').click(); + await page.waitForLoadState('networkidle'); + await expect(page.locator('#sidebar-count')).toContainText('2 of'); + // Down + await page.locator('#sidebar-decrement').click(); + await page.waitForLoadState('networkidle'); + await expect(page.locator('#sidebar-count')).toContainText('1 of'); + // Down again + await page.locator('#sidebar-decrement').click(); + await page.waitForLoadState('networkidle'); + await expect(page.locator('#sidebar-count')).toContainText('1 of'); + + // Can hide and unhide sidebar + await page.locator('#sidebar-button-close').click(); + // Sidebar should be off screen + await expect(page.locator('#map-sidebar')).not.toBeInViewport(); + // Click again and see sidebar + await page.locator('#sidebar-button').click(); + await expect(page.locator('#map-sidebar')).toBeInViewport(); +}) + +test('user can filter map properties', async ({ page }) => { + const getPropertyCount = async (page: Page) => { + await page.locator('#sidebar-count').waitFor(); + const sidebarCount = await page.locator('#sidebar-count').textContent(); + return parseInt(sidebarCount?.match(/\((.+)\)/)?.at(1)?.split(' ').at(0)?.replaceAll(',', '')!) + } + + await page.goto(BASE_URL); + await loginIDIR(page); + // Leaflet map is visible + await page.waitForLoadState(); + await expect(page.locator('#parcel-map')).toBeVisible(); + // Sidebar has properties (also ensures that properties were loaded first) + await expect(page.locator('.property-row').first()).toBeVisible(); + // Get starting number of properties + const startingNumber = await getPropertyCount(page) + + // Open filter + await page.locator('#map-filter-open').click(); + await expect(page.locator('#map-filter-container')).toBeInViewport(); + + // Enter values in the filters and check if the number goes lower each time + await page.getByLabel('Agencies').click(); + await page.getByRole('combobox', { name: 'Agencies' }).fill('real'); + await page.getByRole('listbox', { name: 'Agencies' }).locator('span').click(); + await page.getByRole('button', { name: 'Filter' }).click(); + await Promise.all([ + page.waitForResponse(resp => resp.url().includes('/search/geo')), + page.getByRole('button', { name: 'Filter' }).click(), + page.waitForTimeout(1000) // Otherwise counter doesn't update in time + ]); + const afterAgencies = await getPropertyCount(page); + expect(afterAgencies).toBeLessThan(startingNumber); + + await page.locator('div').filter({ hasText: /^Property Types$/ }).getByLabel('Open').click(); + await page.getByText('Land', { exact: true }).click(); + await Promise.all([ + page.waitForResponse(resp => resp.url().includes('/search/geo')), + page.getByRole('button', { name: 'Filter' }).click(), + page.waitForTimeout(1000) // Otherwise counter doesn't update in time + ]); + const afterPropertyTypes = await getPropertyCount(page); + expect(afterPropertyTypes).toBeLessThan(afterAgencies); + + await page.locator('div').filter({ hasText: /^Regional Districts$/ }).getByLabel('Open').click(); + await page.getByLabel('Regional Districts').fill('cap'); + await page.getByText('Capital Regional District').click(); + await Promise.all([ + page.waitForResponse(resp => resp.url().includes('/search/geo')), + page.getByRole('button', { name: 'Filter' }).click(), + page.waitForTimeout(1000) // Otherwise counter doesn't update in time + ]); const afterRegionalDistricts = await getPropertyCount(page); + expect(afterRegionalDistricts).toBeLessThan(afterPropertyTypes); + + await page.locator('div').filter({ hasText: /^Administrative Areas$/ }).getByLabel('Open').click(); + await page.getByLabel('Administrative Areas').fill('vic'); + await page.getByRole('option', { name: 'Victoria' }).click(); + await Promise.all([ + page.waitForResponse(resp => resp.url().includes('/search/geo')), + page.getByRole('button', { name: 'Filter' }).click(), + page.waitForTimeout(1000) // Otherwise counter doesn't update in time + ]); const afterAdminAreas = await getPropertyCount(page); + expect(afterAdminAreas).toBeLessThan(afterRegionalDistricts); + + await page.getByLabel('Classifications').click(); + await page.getByText('Core Operational').click(); + await Promise.all([ + page.waitForResponse(resp => resp.url().includes('/search/geo')), + page.getByRole('button', { name: 'Filter' }).click(), + page.waitForTimeout(1000) // Otherwise counter doesn't update in time + ]); const afterClassifications = await getPropertyCount(page); + expect(afterClassifications).toBeLessThan(afterAdminAreas); + + // Clear and get back original number + await Promise.all([ + page.waitForResponse(resp => resp.url().includes('/search/geo')), + page.getByRole('button', { name: 'Clear' }).click(), + page.waitForTimeout(1000) // Otherwise counter doesn't update in time + ]); + const afterClear = await getPropertyCount(page); + expect(afterClear).toEqual(startingNumber); + + // Check visibility of filter when opening and closing sidebar + await page.locator('#sidebar-button-close').click(); + await page.waitForTimeout(500); + await expect(page.locator('#map-filter-container')).not.toBeInViewport(); + // Return sidebar/filter + await page.locator('#sidebar-button').getByRole('img').click(); + await expect(page.locator('#map-filter-container')).toBeInViewport(); + // Close filter + await page.locator('#map-sidebar').getByRole('button').first().waitFor(); + await page.locator('#map-sidebar').getByRole('button').first().click(); + await page.waitForTimeout(500); + await expect(page.locator('#map-filter-container')).not.toBeInViewport(); +}) diff --git a/e2e/tests/noAccess.spec.ts b/e2e/tests/noAccess.spec.ts new file mode 100644 index 000000000..44af29116 --- /dev/null +++ b/e2e/tests/noAccess.spec.ts @@ -0,0 +1,33 @@ +/** + * Testing results when the user has no access to PIMS due to their missing role or non-Active status. + * This might be unnecessary, as we are essentially mocking the API response anyway. + */ + +import { test, expect } from "@playwright/test"; +import { loginBCServicesCard } from "../functions/login"; +import { mockSelf } from "../functions/mockRequests"; + + +test('user without a role get the no-role page', async ({ page }) => { + await mockSelf(page, { RoleId: undefined }); + await loginBCServicesCard(page); + await expect(page.getByRole('heading', { name: 'Awaiting Role' })).toBeVisible(); +}) + +test('user with On Hold status should get ____ page', async ({ page }) => { + await mockSelf(page, { Status: 'OnHold' }); + await loginBCServicesCard(page); + await expect(page.getByRole('heading', { name: 'Access Pending' })).toBeVisible(); +}) + +test('user with Disabled status should get ____ page', async ({ page }) => { + await mockSelf(page, { Status: 'Disabled' }); + await loginBCServicesCard(page); + await expect(page.getByRole('heading', { name: 'Account Inactive' })).toBeVisible(); +}) + +test('user with Denied status should get ____ page', async ({ page }) => { + await mockSelf(page, { Status: 'Denied' }); + await loginBCServicesCard(page); + await expect(page.getByRole('heading', { name: 'Account Inactive' })).toBeVisible(); +}) diff --git a/react-app/src/components/map/ParcelMap.tsx b/react-app/src/components/map/ParcelMap.tsx index a07130a35..04cc668ba 100644 --- a/react-app/src/components/map/ParcelMap.tsx +++ b/react-app/src/components/map/ParcelMap.tsx @@ -302,6 +302,7 @@ const ParcelMap = (props: ParcelMapProps) => { {loadProperties ? : <>} { return ( window.open(`/properties/${propertyType}/${id}`)} sx={{ cursor: 'pointer', diff --git a/react-app/src/components/map/sidebar/MapSidebar.tsx b/react-app/src/components/map/sidebar/MapSidebar.tsx index 4a00d846d..b1f21434d 100644 --- a/react-app/src/components/map/sidebar/MapSidebar.tsx +++ b/react-app/src/components/map/sidebar/MapSidebar.tsx @@ -94,12 +94,13 @@ const MapSidebar = (props: MapSidebarProps) => { {/* Sidebar Header */} - setFilterOpen(!filterOpen)}> + setFilterOpen(!filterOpen)} id="map-filter-open"> { if (pageIndex > 0) { @@ -110,10 +111,12 @@ const MapSidebar = (props: MapSidebarProps) => { {`${pageIndex + 1} of ${formatNumber(Math.max(Math.ceil(propertiesInBounds.length / propertyPageSize), 1))} (${formatNumber(propertiesInBounds.length)} items)`} { if (pageIndex + 1 < Math.ceil(propertiesInBounds.length / propertyPageSize)) { @@ -125,7 +128,7 @@ const MapSidebar = (props: MapSidebarProps) => { - setSidebarOpen(false)}> + setSidebarOpen(false)} id="sidebar-button-close"> diff --git a/react-app/src/contexts/snackbarContext.tsx b/react-app/src/contexts/snackbarContext.tsx index 0a787c06e..ade5391fd 100644 --- a/react-app/src/contexts/snackbarContext.tsx +++ b/react-app/src/contexts/snackbarContext.tsx @@ -111,6 +111,7 @@ const SnackBarContextProvider = (props: ISnackBarContext) => { {children}