diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 000000000..9bb0e398d --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,102 @@ +name: E2E Tests + +on: + push: + branches: + - main + pull_request: + branches: + - main + +env: + TURBO_API: 'http://127.0.0.1:9080' + TURBO_TOKEN: 'turbo-token' + TURBO_TEAM: ${{ github.repository_owner }} + +jobs: + run_e2e_tests: + runs-on: ubuntu-latest + steps: + - name: ๐Ÿ“ฅ Checkout repo + uses: actions/checkout@v4 + + - name: ๐Ÿ“ Copy test environment variables + run: cp example.env .env + + - name: ๐Ÿ› ๏ธ Setup node + uses: actions/setup-node@v4 + with: + node-version: 18 + + - name: ๐Ÿ’พ Cache dependencies + id: cache-dependencies + uses: actions/cache@v4 + with: + path: '**/node_modules' + key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} + + - name: ๐Ÿ“ฆ Install dependencies + if: steps.cache-dependencies.outputs.cache-hit != 'true' + run: yarn install --immutable + + - name: ๐ŸŽญ Get installed Playwright version + id: playwright-version + run: echo "PLAYWRIGHT_VERSION=$(grep '@playwright/test@' yarn.lock | sed -n 's/.*npm:\([^":]*\).*/\1/p' | head -n 1)" >> $GITHUB_ENV + + - name: ๐Ÿ’พ Cache Playwright binaries + id: playwright-cache + uses: actions/cache@v4 + with: + path: | + ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }} + + - name: ๐ŸŽญ Install Playwright Browsers + run: npx playwright install chromium --with-deps + if: steps.playwright-cache.outputs.cache-hit != 'true' + + - name: ๐Ÿš€ Setup local cache server for Turborepo + uses: felixmosh/turborepo-gh-artifacts@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + server-token: ${{ env.TURBO_TOKEN }} + + - name: ๐Ÿ—๏ธ Build apps + run: yarn turbo run build --color --concurrency=5 + + - name: ๐Ÿš€ Run dev server + run: bash e2e/support/github/run-e2e-docker-env.sh + + - name: โณ Wait for OpenMRS instance to start + run: while [[ "$(curl -s -o /dev/null -w ''%{http_code}'' http://localhost:8080/openmrs/login.htm)" != "200" ]]; do sleep 10; done + + - name: ๐Ÿงช Run E2E tests + run: yarn test-e2e + + - name: ๐Ÿ›‘ Stop dev server + if: '!cancelled()' + run: docker stop $(docker ps -a -q) + + - name: ๐Ÿ“ค Upload report + uses: actions/upload-artifact@v4 + if: '!cancelled()' + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 + overwrite: true + + - name: ๐Ÿ“ Capture Server Logs + if: always() + uses: jwalton/gh-docker-logs@v2 + with: + dest: './logs' + + - name: ๐Ÿ“ค Upload Logs as Artifact + uses: actions/upload-artifact@v4 + if: always() + with: + name: server-logs + path: './logs' + retention-days: 2 + overwrite: true diff --git a/.gitignore b/.gitignore index 520b3f351..8308f73b7 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,9 @@ dist # i18next parser creating moduleName folder when parsing index.ts moduleName + +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +e2e/storageState.json diff --git a/README.md b/README.md index ef514bfed..08877e990 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,14 @@ By default, `turbo` will cache test runs. This means that re-running tests witho yarn turbo run test --force ``` +## To run end-to-end tests, run: + +```bash +yarn test-e2e +``` + +Read the [e2e testing guide](https://openmrs.atlassian.net/wiki/spaces/docs/pages/150962731/Testing+Frontend+Modules+O3) to learn more about End-to-End tests in this project. + ## Troubleshooting If you notice that your local version of the application is not working or that there's a mismatch between what you see locally versus what's in [dev3](https://dev3.openmrs.org/openmrs/spa), you likely have outdated versions of core libraries. To update core libraries, run the following commands: diff --git a/e2e/core/global-setup.ts b/e2e/core/global-setup.ts new file mode 100644 index 000000000..748974994 --- /dev/null +++ b/e2e/core/global-setup.ts @@ -0,0 +1,32 @@ +import { request } from '@playwright/test'; +import * as dotenv from 'dotenv'; + +dotenv.config(); + +/** + * This configuration is to reuse the signed-in state in the tests + * by log in only once using the API and then skip the log in step for all the tests. + * + * https://playwright.dev/docs/auth#reuse-signed-in-state + */ + +async function globalSetup() { + const requestContext = await request.newContext(); + const token = Buffer.from(`${process.env.E2E_USER_ADMIN_USERNAME}:${process.env.E2E_USER_ADMIN_PASSWORD}`).toString( + 'base64', + ); + await requestContext.post(`${process.env.E2E_BASE_URL}/ws/rest/v1/session`, { + data: { + sessionLocation: process.env.E2E_LOGIN_DEFAULT_LOCATION_UUID, + locale: 'en', + }, + headers: { + Accept: 'application/json', + Authorization: `Basic ${token}`, + }, + }); + await requestContext.storageState({ path: 'e2e/storageState.json' }); + await requestContext.dispose(); +} + +export default globalSetup; diff --git a/e2e/core/index.ts b/e2e/core/index.ts new file mode 100644 index 000000000..607718c2a --- /dev/null +++ b/e2e/core/index.ts @@ -0,0 +1 @@ +export * from './test'; diff --git a/e2e/core/test.ts b/e2e/core/test.ts new file mode 100644 index 000000000..f14d02b75 --- /dev/null +++ b/e2e/core/test.ts @@ -0,0 +1,20 @@ +import { type APIRequestContext, type Page, test as base } from '@playwright/test'; +import { api } from '../fixtures'; + +// This file sets up our custom test harness using the custom fixtures. +// See https://playwright.dev/docs/test-fixtures#creating-a-fixture for details. +// If a spec intends to use one of the custom fixtures, the special `test` function +// exported from this file must be used instead of the default `test` function +// provided by playwright. + +export interface CustomTestFixtures { + loginAsAdmin: Page; +} + +export interface CustomWorkerFixtures { + api: APIRequestContext; +} + +export const test = base.extend({ + api: [api, { scope: 'worker' }], +}); diff --git a/e2e/fixtures/api.ts b/e2e/fixtures/api.ts new file mode 100644 index 000000000..db93f7f9c --- /dev/null +++ b/e2e/fixtures/api.ts @@ -0,0 +1,26 @@ +import { type APIRequestContext, type PlaywrightWorkerArgs, type WorkerFixture } from '@playwright/test'; + +/** + * A fixture which initializes an [`APIRequestContext`](https://playwright.dev/docs/api/class-apirequestcontext) + * that is bound to the configured OpenMRS API server. The context is automatically authenticated + * using the configured admin account. + * + * Use the request context like this: + * ```ts + * test('your test', async ({ api }) => { + * const res = await api.get('patient/1234'); + * await expect(res.ok()).toBeTruthy(); + * }); + * ``` + */ +export const api: WorkerFixture = async ({ playwright }, use) => { + const ctx = await playwright.request.newContext({ + baseURL: `${process.env.E2E_BASE_URL}/ws/rest/v1/`, + httpCredentials: { + username: process.env.E2E_USER_ADMIN_USERNAME || 'admin', + password: process.env.E2E_USER_ADMIN_PASSWORD || 'Admin123', + }, + }); + + await use(ctx); +}; diff --git a/e2e/fixtures/index.ts b/e2e/fixtures/index.ts new file mode 100644 index 000000000..b1c13e734 --- /dev/null +++ b/e2e/fixtures/index.ts @@ -0,0 +1 @@ +export * from './api'; diff --git a/e2e/pages/index.ts b/e2e/pages/index.ts new file mode 100644 index 000000000..fb048c7b4 --- /dev/null +++ b/e2e/pages/index.ts @@ -0,0 +1 @@ +export * from './sample-test'; diff --git a/e2e/pages/sample-test.ts b/e2e/pages/sample-test.ts new file mode 100644 index 000000000..36405aa8d --- /dev/null +++ b/e2e/pages/sample-test.ts @@ -0,0 +1,9 @@ +import { type Page } from '@playwright/test'; + +export class HomePage { + constructor(readonly page: Page) {} + + async goto() { + await this.page.goto('home'); + } +} diff --git a/e2e/specs/sample-test.spec.ts b/e2e/specs/sample-test.spec.ts new file mode 100644 index 000000000..4e6189591 --- /dev/null +++ b/e2e/specs/sample-test.spec.ts @@ -0,0 +1,10 @@ +import { test, expect } from '@playwright/test'; +import { HomePage } from '../pages'; + +// This test is a sample E2E test. You can delete it. + +test('sample-test', async ({ page }) => { + const homePage = new HomePage(page); + await homePage.goto(); + await expect(page.getByText('Clinic')).toBeVisible(); +}); diff --git a/e2e/support/github/Dockerfile b/e2e/support/github/Dockerfile new file mode 100644 index 000000000..682360321 --- /dev/null +++ b/e2e/support/github/Dockerfile @@ -0,0 +1,34 @@ +# syntax=docker/dockerfile:1.3 +FROM --platform=$BUILDPLATFORM node:18-alpine as dev + +ARG APP_SHELL_VERSION=next + +RUN mkdir -p /app +WORKDIR /app + +COPY . . + +RUN npm_config_legacy_peer_deps=true npm install -g openmrs@${APP_SHELL_VERSION:-next} +ARG CACHE_BUST +RUN npm_config_legacy_peer_deps=true openmrs assemble --manifest --mode config --config spa-assemble-config.json --target ./spa + +FROM --platform=$BUILDPLATFORM openmrs/openmrs-reference-application-3-frontend:nightly as frontend +FROM nginx:1.23-alpine + +RUN apk update && \ + apk upgrade && \ + # add more utils for sponge to support our startup script + apk add --no-cache moreutils + +# clear any default files installed by nginx +RUN rm -rf /usr/share/nginx/html/* + +COPY --from=frontend /etc/nginx/nginx.conf /etc/nginx/nginx.conf +# this assumes that NOTHING in the framework is in a subdirectory +COPY --from=frontend /usr/share/nginx/html/* /usr/share/nginx/html/ +COPY --from=frontend /usr/local/bin/startup.sh /usr/local/bin/startup.sh +RUN chmod +x /usr/local/bin/startup.sh + +COPY --from=dev /app/spa/ /usr/share/nginx/html/ + +CMD ["/usr/local/bin/startup.sh"] diff --git a/e2e/support/github/docker-compose.yml b/e2e/support/github/docker-compose.yml new file mode 100644 index 000000000..b95aaf19f --- /dev/null +++ b/e2e/support/github/docker-compose.yml @@ -0,0 +1,24 @@ +# This docker compose file is used to create a backend environment for the e2e.yml workflow. +version: '3.7' + +services: + gateway: + image: openmrs/openmrs-reference-application-3-gateway:${TAG:-nightly} + ports: + - '8080:80' + + frontend: + build: + context: . + environment: + SPA_PATH: /openmrs/spa + API_URL: /openmrs + + backend: + image: openmrs/openmrs-reference-application-3-backend:nightly-with-data + depends_on: + - db + + # MariaDB + db: + image: openmrs/openmrs-reference-application-3-db:nightly-with-data diff --git a/e2e/support/github/run-e2e-docker-env.sh b/e2e/support/github/run-e2e-docker-env.sh new file mode 100644 index 000000000..7d3760d1e --- /dev/null +++ b/e2e/support/github/run-e2e-docker-env.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash -eu + +# get the dir containing the script +script_dir=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) +# create a temporary working directory +working_dir=$(mktemp -d "${TMPDIR:-/tmp/}openmrs-e2e-frontends.XXXXXXXXXX") +# get the app name +app_name=$(jq -r '.name' "$script_dir/../../../package.json") + +echo "Creating packed archive of the app..." +# @openmrs/esm-whatever -> _openmrs_esm_whatever +packed_app_name=$(echo "$app_name" | tr '[:punct:]' '_'); +# run yarn pack for our app and add it to the working directory +yarn pack -o "$working_dir/$packed_app_name.tgz" >/dev/null; +echo "Created packed app archives" + +echo "Creating dynamic spa-assemble-config.json..." +# dynamically assemble our list of frontend modules, prepending the login app and +# primary navigation apps; apps will all be in the /app directory of the Docker +# container +jq -n \ + --arg app_name "$app_name" \ + --arg app_file "/app/$packed_app_name.tgz" \ + '{"@openmrs/esm-primary-navigation-app": "next"} + { + ($app_name): $app_file + }' | jq '{"frontendModules": .}' > "$working_dir/spa-assemble-config.json" +echo "Created dynamic spa-assemble-config.json" + +echo "Copying Docker configuration..." +cp "$script_dir/Dockerfile" "$working_dir/Dockerfile" +cp "$script_dir/docker-compose.yml" "$working_dir/docker-compose.yml" + +cd $working_dir +echo "Starting Docker containers..." +# CACHE_BUST to ensure the assemble step is always run +docker compose build --build-arg CACHE_BUST=$(date +%s) frontend +docker compose up -d diff --git a/example.env b/example.env new file mode 100644 index 000000000..e1bb001ab --- /dev/null +++ b/example.env @@ -0,0 +1,6 @@ +# This is an example environment file for configuring dynamic values. +E2E_BASE_URL=http://localhost:8080/openmrs +E2E_USER_ADMIN_USERNAME=admin +E2E_USER_ADMIN_PASSWORD=Admin123 +E2E_LOGIN_DEFAULT_LOCATION_UUID=44c3efb0-2583-4c80-a79e-1f756a03c0a1 +# The above location UUID is for the "Outpatient Clinic" location in the reference application diff --git a/package.json b/package.json index 3eb83cc19..4afcfba95 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,13 @@ "release": "yarn workspaces foreach --all --topological version", "postinstall": "husky install", "start": "openmrs develop --sources 'packages/esm-*-app/'", - "verify": "turbo lint typescript test" + "verify": "turbo lint typescript test", + "test-e2e": "playwright test" }, "devDependencies": { "@carbon/react": "^1.71.0", "@openmrs/esm-framework": "next", + "@playwright/test": "^1.50.1", "@swc/cli": "^0.1.65", "@swc/core": "^1.7.14", "@swc/jest": "^0.2.36", @@ -62,5 +64,9 @@ "*.{ts,tsx}": "eslint --cache --fix --max-warnings 0", "*.{css,scss,ts,tsx}": "prettier --write --list-different" }, - "packageManager": "yarn@4.4.0" + "packageManager": "yarn@4.4.0", + "dependencies": { + "dotenv": "^16.4.7", + "playwright": "^1.50.1" + } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..befc8d745 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,32 @@ +import { devices, type PlaywrightTestConfig } from '@playwright/test'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +// See https://playwright.dev/docs/test-configuration. +const config: PlaywrightTestConfig = { + testDir: './e2e/specs', + timeout: 3 * 60 * 1000, + expect: { + timeout: 40 * 1000, + }, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: 0, + reporter: process.env.CI ? [['junit', { outputFile: 'results.xml' }], ['html']] : [['html']], + globalSetup: require.resolve('./e2e/core/global-setup'), + use: { + baseURL: `${process.env.E2E_BASE_URL}/spa/`, + storageState: 'e2e/storageState.json', + video: 'retain-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + ], +}; + +export default config; diff --git a/yarn.lock b/yarn.lock index 185b0fc6f..7f79be57e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3300,6 +3300,7 @@ __metadata: dependencies: "@carbon/react": "npm:^1.71.0" "@openmrs/esm-framework": "npm:next" + "@playwright/test": "npm:^1.50.1" "@swc/cli": "npm:^0.1.65" "@swc/core": "npm:^1.7.14" "@swc/jest": "npm:^0.2.36" @@ -3316,6 +3317,7 @@ __metadata: concurrently: "npm:^8.2.2" cross-env: "npm:^7.0.3" css-loader: "npm:^6.11.0" + dotenv: "npm:^16.4.7" eslint: "npm:^8.57.0" eslint-plugin-import: "npm:^2.31.0" eslint-plugin-react-hooks: "npm:^4.6.2" @@ -3329,6 +3331,7 @@ __metadata: jest-environment-jsdom: "npm:^29.7.0" lint-staged: "npm:^14.0.1" openmrs: "npm:next" + playwright: "npm:^1.50.1" prettier: "npm:^3.3.3" react: "npm:^18.2.0" react-dom: "npm:^18.2.0" @@ -3513,6 +3516,17 @@ __metadata: languageName: node linkType: hard +"@playwright/test@npm:^1.50.1": + version: 1.50.1 + resolution: "@playwright/test@npm:1.50.1" + dependencies: + playwright: "npm:1.50.1" + bin: + playwright: cli.js + checksum: 10/0d8d2291d6554c492cb163b4d463e1e9cc6d3ae50680d790473f693f36a243c16c3620406849dd40115046c47a6ad5cc36a24511caec6d054dc1a1d9fffb4138 + languageName: node + linkType: hard + "@pnpm/config.env-replace@npm:^1.1.0": version: 1.1.0 resolution: "@pnpm/config.env-replace@npm:1.1.0" @@ -9165,6 +9179,13 @@ __metadata: languageName: node linkType: hard +"dotenv@npm:^16.4.7": + version: 16.4.7 + resolution: "dotenv@npm:16.4.7" + checksum: 10/f13bfe97db88f0df4ec505eeffb8925ec51f2d56a3d0b6d916964d8b4af494e6fb1633ba5d09089b552e77ab2a25de58d70259b2c5ed45ec148221835fc99a0c + languageName: node + linkType: hard + "downshift@npm:8.1.0": version: 8.1.0 resolution: "downshift@npm:8.1.0" @@ -10485,7 +10506,7 @@ __metadata: languageName: node linkType: hard -"fsevents@npm:^2.3.2, fsevents@npm:~2.3.2": +"fsevents@npm:2.3.2, fsevents@npm:^2.3.2, fsevents@npm:~2.3.2": version: 2.3.2 resolution: "fsevents@npm:2.3.2" dependencies: @@ -10495,7 +10516,7 @@ __metadata: languageName: node linkType: hard -"fsevents@patch:fsevents@npm%3A^2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin": +"fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A^2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin": version: 2.3.2 resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1" dependencies: @@ -14792,6 +14813,30 @@ __metadata: languageName: node linkType: hard +"playwright-core@npm:1.50.1": + version: 1.50.1 + resolution: "playwright-core@npm:1.50.1" + bin: + playwright-core: cli.js + checksum: 10/9a310b8a66bf7fd926e620c1c8e27be29bdbdce91640e5f975b2fd4dc706d0307faec2bb0456cc8e7dedb1e71c0b5eb35c6a58acd5cedc7d8fd849a9067e637b + languageName: node + linkType: hard + +"playwright@npm:1.50.1, playwright@npm:^1.50.1": + version: 1.50.1 + resolution: "playwright@npm:1.50.1" + dependencies: + fsevents: "npm:2.3.2" + playwright-core: "npm:1.50.1" + dependenciesMeta: + fsevents: + optional: true + bin: + playwright: cli.js + checksum: 10/a3687614ac3238a81cbe3018e4f4a2ae92c71f3f65110cc6087068c020f6134f0628308da33177b9b08102644706e835d4053f6890beeb4a935f433bc4ac107a + languageName: node + linkType: hard + "pngjs@npm:^3.0.0, pngjs@npm:^3.3.3": version: 3.4.0 resolution: "pngjs@npm:3.4.0"