Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(feat): Configure e2e for the RFE #283

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"env": {
"node": true
},
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
"extends": ["eslint:recommended", "plugin:playwright/recommended", "plugin:@typescript-eslint/recommended"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": true,
Expand Down
14 changes: 13 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,16 @@ typings/
!.yarn/versions

# vscode
.vscode/*
.vscode/*

# Playwright and e2e tests
e2e/storageState.json
results.xml
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
2 changes: 2 additions & 0 deletions e2e/commands/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './patient-operations';
export * from './visit-operations';
105 changes: 105 additions & 0 deletions e2e/commands/patient-operations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { type APIRequestContext, expect } from '@playwright/test';

export interface Patient {
uuid: string;
identifiers: Identifier[];
display: string;
person: {
uuid: string;
display: string;
gender: string;
age: number;
birthdate: string;
birthdateEstimated: boolean;
dead: boolean;
deathDate?: string;
causeOfDeath?: string;
preferredAddress: {
address1: string;
cityVillage: string;
country: string;
postalCode: string;
stateProvince: string;
countyDistrict: string;
};
attributes: Array<Record<string, unknown>>;
voided: boolean;
birthtime?: string;
deathdateEstimated: boolean;
resourceVersion: string;
};
attributes: { value: string; attributeType: { uuid: string; display: string } }[];
voided: boolean;
}

export interface Address {
preferred: boolean;
address1: string;
cityVillage: string;
country: string;
postalCode: string;
stateProvince: string;
}

export interface Identifier {
uuid: string;
display: string;
}

export const generateRandomPatient = async (api: APIRequestContext): Promise<Patient> => {
const identifierRes = await api.post('idgen/identifiersource/8549f706-7e85-4c1d-9424-217d50a2988b/identifier', {
data: {},
});
await expect(identifierRes.ok()).toBeTruthy();
const { identifier } = await identifierRes.json();

const patientRes = await api.post('patient', {
// TODO: This is not configurable right now. It probably should be.
data: {
identifiers: [
{
identifier,
identifierType: '05a29f94-c0ed-11e2-94be-8c13b969e334',
location: '44c3efb0-2583-4c80-a79e-1f756a03c0a1',
preferred: true,
},
],
person: {
addresses: [
{
address1: 'Bom Jesus Street',
address2: '',
cityVillage: 'Recife',
country: 'Brazil',
postalCode: '50030-310',
stateProvince: 'Pernambuco',
},
],
attributes: [],
birthdate: '2020-2-1',
birthdateEstimated: true,
dead: false,
gender: 'M',
names: [
{
familyName: `Smith${Math.floor(Math.random() * 10000)}`,
givenName: `John${Math.floor(Math.random() * 10000)}`,
middleName: '',
preferred: true,
},
],
},
},
});
await expect(patientRes.ok()).toBeTruthy();
return await patientRes.json();
};

export const getPatient = async (api: APIRequestContext, uuid: string): Promise<Patient> => {
const patientRes = await api.get(`patient/${uuid}?v=full`);
return await patientRes.json();
};

export const deletePatient = async (api: APIRequestContext, uuid: string) => {
await api.delete(`patient/${uuid}`, { data: {} });
};
31 changes: 31 additions & 0 deletions e2e/commands/visit-operations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { type APIRequestContext, expect } from '@playwright/test';
import { type Visit } from '@openmrs/esm-framework';
import dayjs from 'dayjs';

export const startVisit = async (api: APIRequestContext, patientId: string): Promise<Visit> => {
const visitRes = await api.post('visit', {
data: {
startDatetime: dayjs().subtract(1, 'D').format('YYYY-MM-DDTHH:mm:ss.SSSZZ'),
patient: patientId,
location: process.env.E2E_LOGIN_DEFAULT_LOCATION_UUID,
visitType: '7b0f5697-27e3-40c4-8bae-f4049abfb4ed',
attributes: [],
},
});

await expect(visitRes.ok()).toBeTruthy();
return await visitRes.json();
};

export const endVisit = async (api: APIRequestContext, uuid: string) => {
const visitRes = await api.post(`visit/${uuid}`, {
data: {
location: process.env.E2E_LOGIN_DEFAULT_LOCATION_UUID,
startDatetime: dayjs().subtract(1, 'D').format('YYYY-MM-DDTHH:mm:ss.SSSZZ'),
visitType: '7b0f5697-27e3-40c4-8bae-f4049abfb4ed',
stopDatetime: dayjs().format('YYYY-MM-DDTHH:mm:ss.SSSZZ'),
},
});

return await visitRes.json();
};
32 changes: 32 additions & 0 deletions e2e/core/global-setup.ts
Original file line number Diff line number Diff line change
@@ -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: {
'Content-Type': 'application/json',
Authorization: `Basic ${token}`,
},
});
await requestContext.storageState({ path: 'e2e/storageState.json' });
await requestContext.dispose();
}

export default globalSetup;
1 change: 1 addition & 0 deletions e2e/core/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './test';
21 changes: 21 additions & 0 deletions e2e/core/test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { APIRequestContext, Page } from '@playwright/test';
import { 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<CustomTestFixtures, CustomWorkerFixtures>({
api: [api, { scope: 'worker' }],
});
26 changes: 26 additions & 0 deletions e2e/fixtures/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { APIRequestContext, PlaywrightWorkerArgs, 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<APIRequestContext, PlaywrightWorkerArgs> = 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!,
password: process.env.E2E_USER_ADMIN_PASSWORD!,
},
});

await use(ctx);
};
1 change: 1 addition & 0 deletions e2e/fixtures/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './api';
11 changes: 11 additions & 0 deletions e2e/pages/chart-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { type Page } from '@playwright/test';

export class ChartPage {
constructor(readonly page: Page) {}

readonly formsTable = () => this.page.getByRole('table', { name: /forms/i });

async goTo(patientUuid: string) {
await this.page.goto('/openmrs/spa/patient/' + patientUuid + '/chart/Patient%20Summary');
}
}
2 changes: 2 additions & 0 deletions e2e/pages/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './chart-page';
export * from './visits-page';
9 changes: 9 additions & 0 deletions e2e/pages/visits-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { type Page } from '@playwright/test';

export class VisitsPage {
constructor(readonly page: Page) {}

async goTo(patientUuid: string) {
await this.page.goto(`patient/${patientUuid}/chart/Visits`);
}
}
105 changes: 105 additions & 0 deletions e2e/specs/clinical-forms.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { expect } from '@playwright/test';
import { type Visit } from '@openmrs/esm-framework';
import { test } from '../core';
import { generateRandomPatient, deletePatient, type Patient, startVisit, endVisit } from '../commands';
import { ChartPage, VisitsPage } from '../pages';

let patient: Patient;
let visit: Visit;

const subjectiveFindings = `I've had a headache for the last two days`;
const objectiveFindings = `General appearance is healthy. No signs of distress. Head exam shows no abnormalities, no tenderness on palpation. Neurological exam is normal; cranial nerves intact, normal gait and coordination.`;
const assessment = `Diagnosis: Tension-type headache. Differential Diagnoses: Migraine, sinusitis, refractive error.`;
const plan = `Advise use of over-the-counter ibuprofen as needed for headache pain. Educate about proper posture during reading and screen time; discuss healthy sleep hygiene. Schedule a follow-up appointment in 2 weeks or sooner if the headache becomes more frequent or severe.`;

test.beforeEach(async ({ api }) => {
patient = await generateRandomPatient(api);
visit = await startVisit(api, patient.uuid);
});

test('Fill a clinical form', async ({ page }) => {
const chartPage = new ChartPage(page);
const visitsPage = new VisitsPage(page);

await test.step('When I visit the chart summary page', async () => {
await chartPage.goTo(patient.uuid);
});

await test.step('And I click the `Clinical forms` button on the siderail', async () => {
await chartPage.page.getByLabel(/clinical forms/i, { exact: true }).click();
});

await test.step('Then I should see the clinical forms workspace', async () => {
const headerRow = chartPage.formsTable().locator('thead > tr');

await expect(chartPage.page.getByPlaceholder(/search this list/i)).toBeVisible();
await expect(headerRow).toContainText(/form name \(a-z\)/i);
await expect(headerRow).toContainText(/last completed/i);

await expect(chartPage.page.getByRole('cell', { name: /covid 19/i })).toBeVisible();
await expect(chartPage.page.getByRole('cell', { name: /laboratory test results/i })).toBeVisible();
await expect(chartPage.page.getByRole('cell', { name: /soap note template/i })).toBeVisible();
await expect(chartPage.page.getByRole('cell', { name: /surgical operation/i })).toBeVisible();
});

await test.step('When I click the `Soap note template` link to launch the form', async () => {
await chartPage.page.getByText(/soap note template/i).click();
});

await test.step('Then I should see the `Soap note template` form launch in the workspace', async () => {
await expect(chartPage.page.getByText(/soap note template/i)).toBeVisible();
});

await test.step('When I fill the `Subjective findings` question', async () => {
await chartPage.page.locator('#SOAPSubjectiveFindingsid').fill(subjectiveFindings);
});

await test.step('And I fill the `Objective findings` question', async () => {
await chartPage.page.locator('#SOAPObjectiveFindingsid').fill(objectiveFindings);
});

await test.step('And I fill the `Assessment` question', async () => {
await chartPage.page.locator('#SOAPAssessmentid').fill(assessment);
});

await test.step('And I fill the `Plan` question', async () => {
await chartPage.page.locator('#SOAPPlanid').fill(plan);
});

await test.step('And I click on the `Save and close` button', async () => {
await chartPage.page.getByRole('button', { name: /save and close/i }).click();
});

await test.step('Then I should see a success notification', async () => {
await expect(chartPage.page.getByText(/the form has been submitted successfully/i)).toBeVisible();
});

await test.step('And if I navigate to the visits dashboard', async () => {
await visitsPage.goTo(patient.uuid);
});

await test.step('Then I should see the newly filled form in the encounters table', async () => {
await expect(visitsPage.page.getByRole('tab', { name: /visit summaries/i })).toBeVisible();
await expect(visitsPage.page.getByRole('tab', { name: /all encounters/i })).toBeVisible();

await visitsPage.page.getByRole('tab', { name: /^encounters$/i }).click();

const headerRow = visitsPage.page.getByRole('table').locator('thead > tr');

await expect(headerRow).toContainText(/date & time/i);
await expect(headerRow).toContainText(/encounter type/i);
await expect(headerRow).toContainText(/provider/i);

await visitsPage.page.getByRole('table').locator('th#expand').click();

await expect(visitsPage.page.getByText(subjectiveFindings)).toBeVisible();
await expect(visitsPage.page.getByText(objectiveFindings)).toBeVisible();
await expect(visitsPage.page.getByText(assessment)).toBeVisible();
await expect(visitsPage.page.getByText(plan)).toBeVisible();
});
});

test.afterEach(async ({ api }) => {
await endVisit(api, visit.uuid);
await deletePatient(api, patient.uuid);
});
Loading
Loading