diff --git a/care.config.ts b/care.config.ts index dc09c10f398..c5cbf0b6e77 100644 --- a/care.config.ts +++ b/care.config.ts @@ -50,6 +50,73 @@ const resolveApiUrl = (): string => { return env.REACT_CARE_API_URL ?? ""; }; +/** + * E2E test-only runtime overrides for patient-registration config. + * + * Build-time `import.meta.env.REACT_*` values are inlined and frozen into the + * bundle, so Playwright specs (which run against the production `preview` + * build) cannot flip them per test file. To make per-file control possible, a + * spec sets `window.__CARE_E2E_CONFIG__` via `page.addInitScript(...)` — which + * runs before this module loads — and the values below are consulted first. + * + * This seam is gated behind the `REACT_ENABLE_E2E_CONFIG_OVERRIDES` build flag + * (default off), so it is completely inert in normal production builds. The + * affected flags only relax client-side form validation; the backend + * independently enforces required fields, so this is not a security boundary. + */ +interface E2EConfigOverrides { + minGeoOrganizationLevelsRequired?: number; + minimalPatientRegistration?: boolean; +} + +/** + * Reads E2E overrides from `window.__CARE_E2E_CONFIG__`. + * + * Because the object is runtime-injected (via `page.addInitScript`), it cannot + * be trusted to have the right shape: values may be missing, of the wrong type, + * or out of range. Each field is therefore type-checked and coerced, and + * anything malformed is ignored (treated as "no override") rather than trusted + * blindly. This prevents e.g. a string `"false"` from making a boolean flag + * truthy, or an invalid geo-org level from being applied. + */ +function readE2EConfigOverrides(): E2EConfigOverrides { + if ( + !booleanFromString(env.REACT_ENABLE_E2E_CONFIG_OVERRIDES, false) || + typeof window === "undefined" + ) { + return {}; + } + + // Signal to E2E specs that the seam is active in this build (i.e. built with + // `REACT_ENABLE_E2E_CONFIG_OVERRIDES=true`), so they can skip themselves when + // run against a build that does not enable it. + ( + window as unknown as { __CARE_E2E_CONFIG_ENABLED__?: boolean } + ).__CARE_E2E_CONFIG_ENABLED__ = true; + + const raw = (window as unknown as { __CARE_E2E_CONFIG__?: unknown }) + .__CARE_E2E_CONFIG__; + if (typeof raw !== "object" || raw === null) { + return {}; + } + + const source = raw as Record; + const overrides: E2EConfigOverrides = {}; + + if (typeof source.minimalPatientRegistration === "boolean") { + overrides.minimalPatientRegistration = source.minimalPatientRegistration; + } + + const levels = source.minGeoOrganizationLevelsRequired; + if (typeof levels === "number" && Number.isInteger(levels) && levels >= 1) { + overrides.minGeoOrganizationLevelsRequired = levels; + } + + return overrides; +} + +const e2eConfigOverrides: E2EConfigOverrides = readE2EConfigOverrides(); + const careConfig = { apiUrl: resolveApiUrl(), sbomBaseUrl: env.REACT_SBOM_BASE_URL || "https://sbom.ohc.network", @@ -79,8 +146,7 @@ const careConfig = { : undefined), defaultDischargeDisposition: env.REACT_DEFAULT_DISCHARGE_DISPOSITION as - | EncounterDischargeDisposition - | undefined, + EncounterDischargeDisposition | undefined, mapFallbackUrlTemplate: env.REACT_MAPS_FALLBACK_URL_TEMPLATE || @@ -274,19 +340,19 @@ const careConfig = { * If not set, all levels are required. */ minGeoOrganizationLevelsRequired: - env.REACT_PATIENT_REG_MIN_GEO_ORG_LEVELS_REQUIRED + e2eConfigOverrides.minGeoOrganizationLevelsRequired ?? + (env.REACT_PATIENT_REG_MIN_GEO_ORG_LEVELS_REQUIRED ? Math.max( parseInt(env.REACT_PATIENT_REG_MIN_GEO_ORG_LEVELS_REQUIRED, 10), 1, ) - : undefined, + : undefined), defaultGeoOrganization: env.REACT_PATIENT_REGISTRATION_DEFAULT_GEO_ORG, - minimalPatientRegistration: booleanFromString( - env.REACT_ENABLE_MINIMAL_PATIENT_REGISTRATION, - false, - ), + minimalPatientRegistration: + e2eConfigOverrides.minimalPatientRegistration ?? + booleanFromString(env.REACT_ENABLE_MINIMAL_PATIENT_REGISTRATION, false), globalPatientEditAccessEnabled: booleanFromString( env.REACT_PATIENT_GLOBAL_EDIT_ACCESS_ENABLED, diff --git a/package.json b/package.json index 817b8d13f41..189ed61e57c 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "build:react": "cross-env NODE_ENV=production vite build", "supported-browsers": "node ./scripts/generate-supported-browsers.mjs", "build": "npm run build:meta && npm run supported-browsers && npm run build:react", + "build:e2e": "cross-env REACT_ENABLE_E2E_CONFIG_OVERRIDES=true npm run build", "postinstall": "tsx scripts/install-platform-deps.ts && tsx scripts/generate-headers.ts", "test": "snyk test", "test:unit": "node --import tsx --test \"plugins/**/*.test.ts\"", diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 62288995550..dbc7bbd1601 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -34,6 +34,7 @@ interface ImportMetaEnv { readonly REACT_PATIENT_GLOBAL_EDIT_ACCESS_ENABLED?: string; readonly REACT_DISABLE_PATIENT_LOGIN?: string; readonly REACT_ENABLE_QUESTIONNAIRE_DRAFT?: string; + readonly REACT_ENABLE_E2E_CONFIG_OVERRIDES?: string; readonly REACT_CUSTOM_REMOTE_I18N_URL?: string; readonly REACT_ENABLE_AUTO_INVOICE_AFTER_DISPENSE?: string; readonly REACT_ENABLE_TOKEN_GENERATION_IN_PATIENT_HOME?: string; diff --git a/tests/facility/patient/patientRegistration.spec.ts b/tests/facility/patient/patientRegistration.spec.ts index f7db69acace..d5f02274470 100644 --- a/tests/facility/patient/patientRegistration.spec.ts +++ b/tests/facility/patient/patientRegistration.spec.ts @@ -1,5 +1,9 @@ import { faker } from "@faker-js/faker"; import { expect, Page, test } from "@playwright/test"; +import { + applyCareConfig, + isCareConfigOverrideActive, +} from "tests/helper/careConfig"; import { getFacilityId } from "tests/support/facilityId"; // Use the authenticated state @@ -150,6 +154,34 @@ async function submitRegistration(page: Page) { }); } +/** + * Verifies the newly registered patient is shown on the patients home card. + * After registration the app navigates to `/patients/home`, where + * `PatientHoverCard` renders the patient name (heading) and an + * "{age} Y, {gender}" line. + */ +async function verifyPatientCard( + page: Page, + data: { name: string; gender: string }, +) { + await test.step("Verify patient details in the card", async () => { + await page.waitForURL("**/patients/home**"); + // Scope to the specific patient's card so the age/gender assertion cannot + // match another patient's line elsewhere on the page. Using `has` with the + // name heading also excludes the hidden (mobile) hover-card trigger. + const patientCard = page.locator( + '[data-slot="patient-info-hover-card-trigger"]', + { has: page.getByRole("heading", { name: data.name }) }, + ); + await expect( + patientCard.getByRole("heading", { name: data.name }), + ).toBeVisible(); + await expect( + patientCard.getByText(new RegExp(`Y,\\s*${data.gender}`, "i")), + ).toBeVisible(); + }); +} + /** * Fills all standard required fields and submits. * Useful for tests where registration is setup, not the focus. @@ -412,3 +444,98 @@ test.describe("DOB timezone validation", () => { ).not.toBeVisible(); }); }); + +/** + * Demonstrates controlling patient-registration config flags per test file, + * without editing `.env.local`. + * + * `applyCareConfig` uses `page.addInitScript`, which runs before any app script + * loads, so `care.config.ts` picks up the values for this spec only (see the + * E2E override seam in that file). + * + * Requires the preview build to be built with `REACT_ENABLE_E2E_CONFIG_OVERRIDES=true` + * so the seam is active — e.g. `npm run build:e2e`. + */ +test.describe("Patient Registration config overrides (per-file)", () => { + // The override is set on each test's own isolated browser context, so it + // applies to that test only and Playwright discards it automatically when the + // context is torn down — no manual teardown needed. + test("minimal registration lets a patient be registered without address or geo org", async ({ + page, + }) => { + const facilityId = getFacilityId(); + await applyCareConfig(page, { minimalPatientRegistration: true }); + await page.goto(`/facility/${facilityId}/patient/create`); + + test.skip( + !(await isCareConfigOverrideActive(page)), + "E2E config override seam not enabled; build with `npm run build:e2e`", + ); + + const patientData = generatePatientData(); + await fillBasicInfo(page, patientData); + await fillDateOfBirth(page, patientData.dateOfBirth); + await selectBloodGroup(page, patientData.bloodGroup); + + // No address / geo org filled — minimal mode makes them optional. + await submitRegistration(page); + await verifyPatientCard(page, patientData); + }); + + test("lowering required geo org levels to 1 accepts a single selected level", async ({ + page, + }) => { + const facilityId = getFacilityId(); + await applyCareConfig(page, { minGeoOrganizationLevelsRequired: 1 }); + await page.goto(`/facility/${facilityId}/patient/create`); + + test.skip( + !(await isCareConfigOverrideActive(page)), + "E2E config override seam not enabled; build with `npm run build:e2e`", + ); + + const patientData = generatePatientData(); + await fillBasicInfo(page, patientData); + await fillDateOfBirth(page, patientData.dateOfBirth); + await selectBloodGroup(page, patientData.bloodGroup); + + await test.step("Open additional details and select only the first geo org level", async () => { + const additionalDetailsSection = page.getByRole("button", { + name: "Additional Details", + }); + const additionalDetailsSectionText = + await additionalDetailsSection.textContent(); + + if (additionalDetailsSectionText?.toLowerCase().includes("optional")) { + await additionalDetailsSection.click(); + } + + await page + .getByRole("textbox", { name: "Address" }) + .fill(patientData.address); + await page + .getByRole("spinbutton", { name: "PIN Code" }) + .fill(patientData.pincode); + + await page + .getByRole("button", { name: /register patient/i }) + .scrollIntoViewIfNeeded(); + + const geoRegion = page.getByRole("region", { + name: "Additional Details", + }); + const firstCombobox = geoRegion.getByRole("combobox").first(); + await firstCombobox.waitFor({ state: "visible" }); + await firstCombobox.click(); + + const option = page.getByRole("option").first(); + await option.waitFor({ state: "visible" }); + await option.click(); + }); + + // With only 1 level required, a single selection must NOT raise the + // geo-org validation error and registration should succeed. + await submitRegistration(page); + await verifyPatientCard(page, patientData); + }); +}); diff --git a/tests/helper/careConfig.ts b/tests/helper/careConfig.ts new file mode 100644 index 00000000000..a3908094d1b --- /dev/null +++ b/tests/helper/careConfig.ts @@ -0,0 +1,57 @@ +import { type Page } from "@playwright/test"; + +/** + * Patient-registration config flags that can be overridden per test file. + * + * Mirrors the `E2EConfigOverrides` seam in `care.config.ts`, which reads + * `window.__CARE_E2E_CONFIG__` when the app is built with + * `REACT_ENABLE_E2E_CONFIG_OVERRIDES=true` (use `npm run build:e2e`). + */ +export type CareE2EConfig = { + minimalPatientRegistration?: boolean; + minGeoOrganizationLevelsRequired?: number; +}; + +/** + * Overrides CARE runtime config for the current page/context, without editing + * `.env.local`. + * + * Build-time `import.meta.env.REACT_*` values are inlined and frozen into the + * bundle, so per-file control is only possible via a runtime `window` seam. + * `page.addInitScript` runs before any app script loads, so setting + * `window.__CARE_E2E_CONFIG__` here lets `care.config.ts` pick up the values + * for this spec only. + * + * Must be called BEFORE navigating (`page.goto`). + * + * @example + * await applyCareConfig(page, { minimalPatientRegistration: true }); + * await page.goto(`/facility/${facilityId}/patient/create`); + */ +export async function applyCareConfig(page: Page, config: CareE2EConfig) { + await page.addInitScript((cfg) => { + ( + window as unknown as { __CARE_E2E_CONFIG__?: CareE2EConfig } + ).__CARE_E2E_CONFIG__ = cfg; + }, config); +} + +/** + * Reports whether the E2E config-override seam is active in the running build. + * + * The seam is gated behind the `REACT_ENABLE_E2E_CONFIG_OVERRIDES` build flag + * (enabled via `npm run build:e2e`). When active, `care.config.ts` sets + * `window.__CARE_E2E_CONFIG_ENABLED__`. Specs can use this to skip themselves + * when run against a regular build (e.g. CI's default `npm run build`), where + * the overrides would otherwise silently have no effect. + * + * Must be called AFTER navigating (`page.goto`), so the app has loaded. + */ +export async function isCareConfigOverrideActive(page: Page): Promise { + return page.evaluate(() => + Boolean( + (window as unknown as { __CARE_E2E_CONFIG_ENABLED__?: boolean }) + .__CARE_E2E_CONFIG_ENABLED__, + ), + ); +}