From 4bc6c7746b959f8d718acf01a50c54cda127b03c Mon Sep 17 00:00:00 2001 From: nihal467 Date: Thu, 25 Jun 2026 15:20:59 +0530 Subject: [PATCH 1/4] feat: add Playwright E2E testing skill --- playwright/SKILL.md | 576 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 576 insertions(+) create mode 100644 playwright/SKILL.md diff --git a/playwright/SKILL.md b/playwright/SKILL.md new file mode 100644 index 0000000..1668be3 --- /dev/null +++ b/playwright/SKILL.md @@ -0,0 +1,576 @@ +--- +name: playwright +description: "Write, debug, and run Playwright E2E tests for CARE. Use when: creating new test files, fixing failing tests, adding test coverage, writing assertions, using test helpers, setting up test authentication, or running Playwright commands." +argument-hint: "Describe the test scenario or feature to test" +--- + +# Playwright E2E Testing for CARE + +## When to Use + +- Writing new Playwright test files +- Debugging failing E2E tests +- Adding test coverage for features +- Understanding test helpers and selectors +- Running or configuring Playwright tests + +## When NOT to E2E Test + +- **Pure logic/utilities** — validation functions, formatters, calculators → unit test +- **Component rendering** — conditional display, props variations → component test +- **API contract** — request/response shape → integration test or contract test +- **E2E is for user journeys** — login → navigate → fill form → submit → verify result +- **Rule of thumb:** if it doesn't need a browser and a running backend, it's not E2E + +## Setup & Commands + +```bash +npm run playwright:install # Install browsers (first time) +npm run build # Build app (tests run against production build) +npm run playwright:db-reset # Create DB snapshot with fixtures (requires CARE_BACKEND_DIR) +npm run playwright:db-restore # Restore clean DB state before re-runs + +npx playwright test tests/auth/ # Run a specific directory +npx playwright test -g "test name" # Run by pattern +npx playwright test --headed # Headed mode for debugging +npx playwright test --ui # Interactive UI mode +npx playwright show-report # View last HTML report +``` + +## Complete Reference + +The full guide with all patterns, selectors, helpers, and examples is in the project: + +- [Playwright Guide](../../../tests/PLAYWRIGHT_GUIDE.md) — Complete test writing reference including: + - Test file template and structure + - Authentication and storage states + - Form interactions (text, select, combobox, date, radio, checkbox) + - Advanced selector helpers (`selectFromCommand`, `selectFromValueSet`, `selectFromCategoryPicker`, etc.) + - Assertions (toast, table, form errors, visibility) + - Data generation with faker + - File organization conventions + - Common pitfalls + +**Always read the Playwright Guide before writing tests.** + +## File Organization — Mirror the UI Navigation + +Test directory structure MUST mirror the application's navigation hierarchy. Match the sidebar/URL structure: + +``` +tests/ + auth/ # /login, /session, /homepage + admin/ # /admin/* + organizations/ # /admin/organizations + roles/ # /admin/roles + valueset/ # /admin/valueset + questionnaire/ # /admin/questionnaire + tags/ # /admin/tags + patientIdentifierConfig/ # /admin/patient-identifier-config + billing/ # /billing (top-level billing) + organization/ # /organization/* + user/ # /organization/.../users + facility/ # /organization/.../facilities + patient/encounter/ # /organization/.../patient/encounter + facility/ # /facility/:id/* + billing/ # /facility/:id/billing + components/ # Shared facility UI components + queues/ # /facility/:id/queues + services/locations/inventory/ # /facility/:id/services/locations/inventory + settings/ # /facility/:id/settings/* + activityDefinition/ # settings > activity definitions + billing/discount/ # settings > billing > discounts + chargeItemDefinition/ # settings > charge items + departments/ # settings > departments + devices/ # settings > devices + general/ # settings > general + locations/ # settings > locations + observationDefinition/ # settings > observations + product/ # settings > products + productKnowledge/ # settings > product knowledge + specimenDefinitions/ # settings > specimens + tokenCategory/ # settings > token categories + patient/ # /facility/:id/patient/* + patientRegistration.spec.ts # patient registration + patientHome/ # patient home/list + patientDetails/ # patient profile tabs + files/ # files tab + notes/ # notes tab + request/ # requests tab + users/ # users tab + encounter/ # encounter context + careTeam/ # care team + files/drawings/ # drawings + forms/enableWhen/ # questionnaire forms + medicine/ # prescriptions + notes/ # encounter notes + serviceRequests/ # service requests + structuredQuestions/ # allergy, diagnosis, symptoms, etc. + profile/ # /profile/* +``` + +**Naming conventions:** +- Directories: camelCase matching the feature (e.g., `activityDefinition/`, `patientDetails/`) +- Files: `featureAction.spec.ts` (e.g., `locationCreation.spec.ts`, `deviceEdit.spec.ts`, `departmentUserManage.spec.ts`) — camelCase, not PascalCase +- Group CRUD operations in the same directory with separate files per action + +## Quick Reference + +### Test Naming Convention + +- Name describes the **expected outcome**, not the action: `"shows error when name is empty"` not `"test empty name"` +- Use format: `" when "` or `" for "` +- Must be greppable — avoid generic words like "test", "works", "correct" +- Examples: `"creates location with all fields"`, `"rejects duplicate department name"`, `"displays prefilled data on edit form"` + +### Test File Structure + +```typescript +import { faker } from "@faker-js/faker"; +import { expect, test } from "@playwright/test"; +import { getFacilityId } from "tests/support/facilityId"; + +test.use({ storageState: "tests/.auth/user.json" }); + +test.describe("Feature Name", () => { + let facilityId: string; + + test.beforeEach(async ({ page }) => { + facilityId = getFacilityId(); + await page.goto(`/facility/${facilityId}/settings/locations`); + }); + + test("creates location with valid name and type", async ({ page }) => { + await test.step("Fill location form", async () => { + // actions + }); + + await test.step("Verify location appears in list", async () => { + // assertions + }); + }); +}); +``` + +### Auth Storage States + +| Storage State | Role | Username | +|---|---|---| +| `tests/.auth/user.json` | Admin | `admin` | +| `tests/.auth/facilityAdmin.json` | Facility Admin | `care-fac-admin` | +| `tests/.auth/nurse.json` | Nurse | `care-nurse` | + +These files are generated (gitignored) by setup specs in `tests/setup/*.setup.ts`. If you hit a missing storage-state file error, run the auth setup first (for example: `npx playwright test tests/setup/auth.setup.ts`). + +### Key Helpers (from `tests/helper/ui`) + +- `expectToast(page, "message")` — Assert toast notification +- `selectFromCommand(page, trigger, { search, itemIndex })` — User/service picker +- `selectFromValueSet(page, trigger, { search, itemIndex })` — Code/body site picker +- `selectFromFilterSelect(page, /label/i, "value")` — Filter select +- `getFieldErrorMessage(locator)` — Get form field error (from `tests/helper/error`) + +### Critical Rules + +1. **Use `faker` for data you create in tests** (entity names, notes, and random array selection for generated values). For selecting existing fixture-backed options (usernames, body sites, etc.), use shared constants in `tests/helper/commonConstants.ts` — avoid scattered hardcoded literals +2. **Always use deterministic fixture IDs** — use `getFacilityId()`, `getPatientId()`, `getEncounterId()` from `tests/support/` for navigation. NEVER select a random encounter/patient/facility from a list in the UI — random selection causes flakiness when data changes between runs +3. Always include `test.use({ storageState })` for authenticated flows (omit only for explicit public/auth-page tests) +4. Use `exact: true` on selectors when partial matches are possible +5. **`.first()` only after searching/filtering** — use `.first()` only when you've searched a list and are selecting from filtered results. Never use it to randomly pick from an unfiltered list +6. Use `test.step()` to organize test actions +7. Place tests in matching feature directory under `tests/` +8. **Verify API responses on form submission** — when feasible, wait for and assert the API response (status code) using `page.waitForResponse()`. Skip if the form triggers multiple chained calls or the response isn't meaningful to assert +9. **Verify page navigation** — after navigating to a new page, assert a heading or unique element is visible to confirm the page fully loaded +10. **Wait for specific UI indicators over hardcoded timeouts** — prefer waiting for a visible element or API response instead of arbitrary `{ timeout: N }` values. Use `page.waitForLoadState("networkidle")` only when no better signal exists (it can be flaky with polling/websockets) +11. **Never add custom test IDs to source code** — never add `data-testid` or any custom attribute to the application code for test targeting. Tests must use existing roles, labels, text, accessible selectors, and existing `data-slot` attributes (these are part of shadcn/ui's component architecture, not added for testing) +12. **Verify in-page generated content** — when something is generated without page change (QR codes, tokens, etc.), always assert both element visibility AND the corresponding API request/response +13. **Prefer constants over duplication** — extract reusable values (known usernames, body sites, common options) into shared constants in `tests/helper/` to avoid duplicates across tests +14. **Verify locally before pushing** — always run the specific test file(s) locally with `npx playwright test ` and confirm they pass before pushing code +15. **3-strike rule: ask for human help** — if a test keeps failing after 3 AI fix attempts, stop and ask the user to manually inspect the UI. The AI may be missing a visual detail, animation, or dynamic behavior that only a human can verify +16. **Do not touch CI/CD YAML** — never modify GitHub Actions workflow files or CI configuration unless explicitly asked to do so. If a test requires a new `.env` variable that works locally, ALERT the human that CI/CD YAML may need updating for it to work in pipelines. +17. **Never use `test.skip()` or conditional skips to hide failures** — if a test is failing, fix it. Never add `.skip()`, `test.fixme()`, or `if` conditions to bypass a failure. These create false positives where the suite appears green but functionality is broken. Only use `.skip()` for genuinely unsupported environments (e.g., OS-specific) with a clear comment explaining why. +18. **Continuously improve this skill** — capture improvements as follow-up changes through a separate human-reviewed PR; do not self-modify the skill during normal test generation. + +## Mindset: Senior QA Engineer + +When writing tests, think as a senior QA engineer — question every decision, challenge every assumption, and choose the best approach possible: + +- **Test user journeys, not implementation** — focus on what the user sees and does, not internal state +- **Cover edge cases** — empty states, boundary values, error states, permission denied, network failures +- **Test both happy path and unhappy path** — form validation errors, unauthorized access, concurrent operations +- **Always include negative tests** — submit forms with missing required fields, enter invalid data formats, attempt actions without permissions, test API error responses (4xx/5xx), verify proper error messages are shown to the user +- **Assert what matters** — verify the outcome the user cares about, not incidental DOM structure +- **Think about test isolation** — each test must be independent; never rely on another test's side effects +- **Consider accessibility** — if a screen reader can't reach it, neither should your test (use roles, labels, not CSS selectors) +- **Regression-first** — when fixing a bug, write the test that would have caught it before fixing the code +- **Data boundaries** — test with minimum valid input, maximum valid input, and just-beyond-boundary invalid input +- **State transitions** — verify the UI correctly reflects state changes (loading → loaded, enabled → disabled, empty → populated) + +### Form Testing Checklist + +Every form MUST have tests covering: + +1. **Required field validation** — submit with all fields empty, verify each required field shows an error +2. **All fields filled (happy path)** — submit with all valid data, verify success +3. **Field combinations** — submit with only some required fields filled, verify correct errors appear for missing ones +4. **Error message validation** — assert exact error text matches for each validation rule (min length, format, required, etc.) +5. **Individual field update (edit forms)** — verify editing one field doesn't corrupt other fields. Test the full edit flow; don't reload per field (that's for unit tests) +6. **Field-level validation** — test invalid formats (email, phone, date), boundary values (min/max length), special characters +7. **Form reset/cancel** — verify cancel doesn't save, form resets properly +8. **Duplicate submission prevention** — verify button disables after click, no double-submit +9. **Post-submission verification (mandatory)** — after every successful form submit, ALWAYS verify ALL of these: + - Toast notification appears with correct message + - URL/path changes to the expected destination + - The newly created/updated data is visible on the redirected page (card, table row, detail view) + - Never stop at just the submit click — the test is incomplete without confirming the data landed correctly +10. **Edit form prefill verification** — when opening an edit form, always assert that existing data is prefilled in all fields before making changes + +## Patterns + +### Debugging Failing Tests + +```bash +# Run with trace viewer (captures screenshots, network, console) +npx playwright test tests/path/to/test.spec.ts --trace on + +# Run in debug mode (step through with Playwright Inspector) +npx playwright test tests/path/to/test.spec.ts --debug + +# Run headed to visually see what's happening +npx playwright test tests/path/to/test.spec.ts --headed + +# View HTML report from last run +npx playwright show-report + +# Open a trace zip directly (if available) +npx playwright show-trace test-results//trace.zip +``` + +When a test fails: +1. Check the HTML report (`npx playwright show-report`) for screenshots and trace +2. Run with `--headed` to visually observe the failure +3. Use `--debug` to step through interactively +4. Check if DB state is stale — run `npm run playwright:db-restore` + +**Flakiness triage (passes sometimes, fails sometimes):** +- **Timing/race condition** → element appears before data loads. Fix: wait for API response or specific text, not `networkidle` +- **Animation/transition** → click happens mid-animation. Fix: wait for element to be stable (`await locator.waitFor({ state: 'visible' })`) before acting +- **Parallel data collision** → two tests create same faker value. Fix: use `Date.now()` suffix or more specific faker seeds +- **Stale DB state** → previous test left data behind. Fix: `npm run playwright:db-restore` or restructure to not depend on clean state +- **Polling/WebSocket** → `networkidle` never resolves. Fix: wait for specific DOM change instead + +### API Response Verification on Form Submit + +```typescript +await test.step("Submit and verify API response", async () => { + const responsePromise = page.waitForResponse( + (resp) => + resp.url().includes("/api/v1/facility/") && resp.request().method() === "POST", + ); + + await page.getByRole("button", { name: "Create" }).click(); + + const response = await responsePromise; + expect(response.status()).toBe(201); +}); +``` + +### Page Load Verification After Navigation + +```typescript +// After clicking a link or submitting that navigates +// Prefer asserting a visible element directly (auto-waits up to timeout) +await expect( + page.getByRole("heading", { name: "Facility Overview" }), +).toBeVisible(); + +// Only use networkidle as last resort when no specific element to wait for +// await page.waitForLoadState("networkidle"); +``` + +### Parallel vs Serial Execution + +```typescript +// DEFAULT: Tests run in parallel and are independent — prefer this always +test.describe("Location validations", () => { + test("shows error for empty name", async ({ page }) => { /* ... */ }); + test("shows error for duplicate slug", async ({ page }) => { /* ... */ }); +}); + +// AVOID: serial creates hidden dependencies and increases flakiness. +// Only use when a single logical flow MUST share state (rare). +// If you need serial, consider making it ONE test with multiple steps instead. +test.describe("Location CRUD", () => { + test.describe.configure({ mode: "serial" }); + test("create location", async ({ page }) => { /* ... */ }); + test("edit location", async ({ page }) => { /* ... */ }); + test("delete location", async ({ page }) => { /* ... */ }); +}); +``` + +### `.fill()` vs `.pressSequentially()` — IMPORTANT + +- **`.fill()`** clears the field first, then sets the value. Use for fresh input. +- **`.pressSequentially()`** types character by character WITHOUT clearing — appends to existing data. +- AI often confuses these. Be explicit: + +```typescript +// CORRECT: Clear and type fresh value +await page.getByRole("textbox", { name: "Name" }).fill("New Value"); + +// CORRECT: Type along with existing data (e.g., slug auto-generation, search) +await page.getByRole("textbox", { name: "Name" }).pressSequentially("appended text"); + +// WRONG: Using fill() when you want to append +// WRONG: Using pressSequentially() when you want to replace +``` + +### Direct API Calls for Test Setup + +Use `fetch()` for creating precondition data without going through UI: + +```typescript +import { getFacilityId } from "tests/support/facilityId"; +import { getApiHeaders, getApiUrl } from "tests/helper/utils"; + +async function createAccountViaApi() { + const facilityId = getFacilityId(); + const res = await fetch(`${getApiUrl()}/api/v1/facility/${facilityId}/account/`, { + method: "POST", + headers: getApiHeaders(), + body: JSON.stringify({ name: "Test Account", type: "income" }), + }); + if (!res.ok) { + throw new Error(`Failed to create account: ${res.status}`); + } + return res.json(); +} + +// Call inside a test or hook — never at module top level +test.beforeEach(async () => { + const account = await createAccountViaApi(); + // ... use account in the test +}); +``` + +### Promise.all() for Parallel Navigation + API Wait + +The wait promise MUST be listed first so the response listener is registered before the action that triggers the request — otherwise the request can fire before Playwright is listening, causing a flaky timeout. + +```typescript +await Promise.all([ + page.waitForResponse( + (resp) => + resp.url().includes("/medication/prescription/") && + resp.status() === 200, + ), + page.getByRole("tab", { name: "Medicines" }).click(), +]); +``` + +### data-slot Selectors + +Components use `data-slot` attributes for stable targeting: + +```typescript +// Table +await expect(page.locator('[data-slot="table-body"]')).toContainText(name); +await page.locator('[data-slot="table-row"]').first().click(); + +// Badge +await expect(page.locator('[data-slot="badge"]').filter({ hasText: "Active" })).toBeVisible(); + +// Collapsible +const card = page.locator('[data-slot="collapsible"]').filter({ hasText: "Lab Tests" }); +await card.locator('[data-slot="collapsible-trigger"]').click(); + +// Command input (search fields) +const scope = page; +const input = scope.locator('[data-slot="command-input"]').first(); +await input.fill(""); // Clear first +await input.fill(search); // Then fill +``` + +### Helper Function Extraction Pattern + +Extract reusable form logic into local helpers that return generated data: + +```typescript +import type { Page } from "@playwright/test"; + +async function createEntity(page: Page, options: { name: string; type: string }) { + await page.getByRole("button", { name: "Create" }).click(); + await page.getByRole("textbox", { name: "Name" }).fill(options.name); + await page.getByRole("combobox", { name: "Type" }).click(); + await page.getByRole("option", { name: options.type }).first().click(); + await page.getByRole("button", { name: "Submit" }).click(); + return options; // Return so test can assert against it +} +``` + +### Cascading/Dependent Form Elements + +Handle comboboxes that appear based on previous selection: + +```typescript +const region = page; +let previousCount = 0; +const MAX_CASCADES = 10; + +for (let iteration = 0; iteration < MAX_CASCADES; iteration++) { + const comboboxes = region.getByRole("combobox"); + const count = await comboboxes.count(); + if (count === previousCount) break; + const combobox = comboboxes.nth(count - 1); + await combobox.click(); + await page.getByRole("option").first().click(); + previousCount = count; + + if (iteration === MAX_CASCADES - 1) { + throw new Error( + `Cascading comboboxes exceeded ${MAX_CASCADES} iterations - possible loop`, + ); + } +} +``` + +### Phone Number Generation (Indian Format) + +```typescript +const phoneNumber = `${faker.helpers.arrayElement([7, 8, 9])}${faker.string.numeric(9)}`; +``` + +### Dismiss Stray Popovers + +```typescript +import { closeAnyOpenPopovers } from "tests/helper/ui"; +await closeAnyOpenPopovers(page); +``` + +### Eventually-Consistent Assertions (`expect.toPass()`) + +For UI that updates asynchronously (e.g., list refreshes after creation): + +```typescript +await expect(async () => { + await expect(page.getByRole("table").getByText(createdName)).toBeVisible(); +}).toPass({ timeout: 10000 }); +``` + +Use when the data appears after a short delay due to cache invalidation or refetch. + +### File Upload & Camera + +For file upload inputs, use `setInputFiles()`: + +```typescript +// Standard file upload +const fileInput = page.locator('input[type="file"]'); +await fileInput.setInputFiles("tests/fixtures/sample_file.xlsx"); + +// Camera/image capture (mimic by uploading an image file) +await fileInput.setInputFiles("tests/fixtures/images/test-image.jpg"); +``` + +Camera inputs are tested by uploading a fixture image file — no actual camera simulation needed. Always verify: +- The uploaded file name/thumbnail appears in the UI +- The API request for upload returns success + +### Delete with Double Confirmation + +Destructive actions typically require two confirmations. Always verify BOTH: + +```typescript +const entityName = ""; + +await test.step("Delete with double confirmation", async () => { + // First: click delete button + await page.getByRole("button", { name: "Delete" }).click(); + + // First confirmation dialog + await expect(page.getByRole("alertdialog")).toBeVisible(); + await page.getByRole("button", { name: "Confirm" }).click(); + + // Second confirmation (if present — e.g., type entity name) + await expect(page.getByRole("alertdialog")).toBeVisible(); + await page.getByRole("textbox").fill("confirm"); + await page.getByRole("button", { name: "Delete" }).click(); + + // Verify deletion + await expectToast(page, /deleted successfully/i); + await expect(page.getByText(entityName)).not.toBeVisible(); +}); +``` + +Never skip a confirmation step — verify both dialogs appear and require interaction. + +### Network Mocking (`page.route()`) + +Use for testing error states without needing the backend to fail: + +```typescript +// Mock a 500 error +await page.route("**/api/v1/facility/*/", (route) => + route.fulfill({ status: 500, body: JSON.stringify({ detail: "Server Error" }) }), +); + +// Mock network timeout +await page.route("**/api/v1/facility/*/", (route) => route.abort("timedout")); + +// Remove mock after test +await page.unroute("**/api/v1/facility/*/"); +``` + +Use sparingly — prefer real backend responses. Only mock when testing specific error UI that's hard to trigger naturally. + +### Multiple User Roles in a Single Test + +When a workflow requires actions from different users (e.g., admin creates, nurse verifies), use `browser.newContext()` with different storage states: + +```typescript +import { expect, test } from "@playwright/test"; + +import { getFacilityId } from "tests/support/facilityId"; + +test("Admin assigns task, nurse sees it", async ({ browser }) => { + const facilityId = getFacilityId(); + + // Admin context — close in `finally` so a failing step still releases the context + const adminContext = await browser.newContext({ + storageState: "tests/.auth/user.json", + }); + try { + const adminPage = await adminContext.newPage(); + await test.step("Admin creates assignment", async () => { + await adminPage.goto(`/facility/${facilityId}/...`); + // ... admin actions + }); + } finally { + await adminContext.close(); + } + + // Nurse context + const nurseContext = await browser.newContext({ + storageState: "tests/.auth/nurse.json", + }); + try { + const nursePage = await nurseContext.newPage(); + await test.step("Nurse verifies assignment", async () => { + await nursePage.goto(`/facility/${facilityId}/...`); + await expect(nursePage.getByText("Assigned Task")).toBeVisible(); + }); + } finally { + await nurseContext.close(); + } +}); +``` + +Always close each context after use. Use `{ browser }` fixture instead of `{ page }` when switching users. + +### Keep Tests Independent + +- Each test MUST be runnable in isolation — never depend on another test's side effects +- Use `test.describe.configure({ mode: "serial" })` only when tests share a logical CRUD flow (create → edit → delete) within the SAME describe block +- If a test needs precondition data, create it in `beforeEach`/`beforeAll` or via direct API calls — never assume a previous test created it +- Tests in DIFFERENT files must NEVER depend on each other +- **Encounter limit awareness** — a patient can have only 5 live encounters at a time. If your test creates encounters or selects existing ones, ensure you mark them as completed (via API or UI) after use. Failing to do so causes flaky failures when the limit is reached across test runs. From bf285d19a8d767a0cbf7082dbeeb42f0f55706dd Mon Sep 17 00:00:00 2001 From: Jacob Jeevan Date: Thu, 25 Jun 2026 16:34:16 +0530 Subject: [PATCH 2/4] chore: minor cleanup, copy over and update guide, add examples --- playwright/PLAYWRIGHT_GUIDE.md | 899 ++++++++++++++++++++++++++ playwright/SKILL.md | 617 ++++-------------- playwright/examples/apiSetup.spec.ts | 119 ++++ playwright/examples/crudForm.spec.ts | 115 ++++ playwright/examples/multiRole.spec.ts | 111 ++++ 5 files changed, 1372 insertions(+), 489 deletions(-) create mode 100644 playwright/PLAYWRIGHT_GUIDE.md create mode 100644 playwright/examples/apiSetup.spec.ts create mode 100644 playwright/examples/crudForm.spec.ts create mode 100644 playwright/examples/multiRole.spec.ts diff --git a/playwright/PLAYWRIGHT_GUIDE.md b/playwright/PLAYWRIGHT_GUIDE.md new file mode 100644 index 0000000..095f919 --- /dev/null +++ b/playwright/PLAYWRIGHT_GUIDE.md @@ -0,0 +1,899 @@ +# Playwright Test Writing Guide (CARE) + +The encyclopedic reference for writing Playwright E2E tests for CARE. `SKILL.md` is +the lean router (when to test, critical rules, mindset, checklists); **this file is +the how-to** — templates, selectors, helpers, assertions, and the full pattern +gallery. Read `SKILL.md` first for the rules, then use this for the mechanics. + +> **Canonical source:** this guide, bundled with the skill, is the single source of +> truth for CARE Playwright patterns. The app repo (`care_fe`) references the skill +> rather than keeping its own copy — edit patterns here, not there. + +## Quick Start + +```bash +# First time setup +npm run playwright:install # Install browsers +npm run build # Build the app (required — tests run against production build) +npm run playwright:db-reset # Create DB snapshot with fixtures (requires CARE_BACKEND_DIR) + +# Run tests (backend must be running on port 9000) +npx playwright test tests/auth/ # Run a specific directory +npx playwright test --workers=4 # Run with parallelism + +# Re-run (DB auto-restores from snapshot) +npx playwright test tests/auth/ # Just run again — clean state guaranteed +``` + +## Test File Template + +Every test file follows this exact structure: + +```typescript +import { faker } from "@faker-js/faker"; +import { expect, test } from "@playwright/test"; +import { getFacilityId } from "tests/support/facilityId"; + +// REQUIRED: Use authenticated storage state +test.use({ storageState: "tests/.auth/user.json" }); + +test.describe("Feature Name", () => { + let facilityId: string; + + test.beforeEach(async ({ page }) => { + facilityId = getFacilityId(); + await page.goto(`/facility/${facilityId}/settings/locations`); + }); + + test("creates location with valid name and type", async ({ page }) => { + await test.step("Fill location form", async () => { + // actions + }); + + await test.step("Verify location appears in list", async () => { + // assertions + }); + }); +}); +``` + +## Authentication + +Use one of these storage states depending on the role needed: + +| Storage State | Role | Credentials | +| -------------------------------- | -------------- | ----------------------------- | +| `tests/.auth/user.json` | Admin | `admin` / `admin` | +| `tests/.auth/facilityAdmin.json` | Facility Admin | `care-fac-admin` / `Ohcn@123` | +| `tests/.auth/nurse.json` | Nurse | `care-nurse` / `Ohcn@123` | + +```typescript +// Most tests use admin +test.use({ storageState: "tests/.auth/user.json" }); + +// Nurse-specific tests +test.use({ storageState: "tests/.auth/nurse.json" }); +``` + +These files are generated (gitignored) by the setup specs in `tests/setup/*.setup.ts`. +If you hit a missing storage-state file error, run the auth setup first: +`npx playwright test tests/setup/auth.setup.ts`. + +## Available IDs from Setup + +```typescript +import { getFacilityId } from "tests/support/facilityId"; +import { getPatientId } from "tests/support/patientId"; +import { getEncounterId } from "tests/support/encounterId"; +import { getAccountId } from "tests/support/accountId"; + +// Use in beforeEach or test body +const facilityId = getFacilityId(); +const patientId = getPatientId(); +const encounterId = getEncounterId(); +const accountId = getAccountId(); +``` + +Always navigate with these deterministic IDs. **Never** pick a random +encounter/patient/facility from a list in the UI — random selection causes +flakiness when data changes between runs. + +## Common URLs + +> **Source of truth:** route definitions live in `src/Routers/routes/` (e.g. +> `FacilityRoutes.tsx`, `PatientRoutes.tsx`). The paths below are illustrative — if a +> URL 404s, check the route files rather than trusting this list, which can lag the app. + +```typescript +// Facility pages +`/facility/${facilityId}/overview` +`/facility/${facilityId}/settings/locations` +`/facility/${facilityId}/settings/departments` +`/facility/${facilityId}/settings/devices` +`/facility/${facilityId}/settings/services` +`/facility/${facilityId}/users` + +// Patient pages +`/facility/${facilityId}/patient/${patientId}/encounter/${encounterId}` +`/facility/${facilityId}/patient/${patientId}/profile` +`/facility/${facilityId}/encounters` + +// Admin pages +`/admin/questionnaire` +`/admin/valueset` +``` + +## Data Generation + +ALWAYS use faker or timestamps for unique data. NEVER hardcode entity names. + +```typescript +import { faker } from "@faker-js/faker"; + +// Names +const name = faker.company.name(); +const departmentName = faker.word.words(2); +const description = faker.lorem.sentence(); + +// With timestamp for guaranteed uniqueness +const uniqueName = `Test ${Date.now()}`; + +// Random selection from options +const status = faker.helpers.arrayElement(["Active", "Inactive"]); + +// Phone numbers (Indian format) — faker-based, the single canonical form. +// Indian mobiles start with 6–9, followed by 9 more digits. +const phone = `${faker.helpers.arrayElement([6, 7, 8, 9])}${faker.string.numeric(9)}`; + +// Slugs (auto-generated from names in the app) +import { expectedSlug } from "tests/helper/utils"; +const slug = expectedSlug(name); // lowercase, hyphens, max 25 chars + +// Non-existent search term (for testing "no results") +const nonExistent = faker.string.uuid(); +``` + +> For values that must match **existing** fixture data (usernames, body sites), +> import the shared constants instead of hardcoding — see +> [Available Constants](#available-constants). + +## Form Interactions + +### Text Input + +```typescript +await page.getByRole("textbox", { name: "Name" }).fill("value"); + +// For inputs that need keystroke simulation (e.g., slug auto-generation) +await page.getByRole("textbox", { name: "Name" }).pressSequentially("value"); +``` + +### `.fill()` vs `.pressSequentially()` — IMPORTANT + +- **`.fill()`** clears the field first, then sets the value. Use for fresh input. +- **`.pressSequentially()`** types character by character WITHOUT clearing — appends + to existing data. Use for slug auto-generation, incremental search, etc. +- AI often confuses these. Be explicit: + +```typescript +// CORRECT: Clear and type a fresh value +await page.getByRole("textbox", { name: "Name" }).fill("New Value"); + +// CORRECT: Type along with existing data (slug auto-generation, search) +await page.getByRole("textbox", { name: "Name" }).pressSequentially("appended text"); +``` + +### Select / Combobox + +```typescript +await page.getByRole("combobox", { name: "Status", exact: true }).click(); +await page.getByRole("option", { name: "Active" }).first().click(); +``` + +**IMPORTANT:** Use `exact: true` when the label might partially match other elements +(e.g. `"Status"` also matches `"Operational Status"`). Use `.first()` on options when +multiple matches are possible. + +### Radio Button + +```typescript +await page.getByRole("radio", { name: "Male", exact: true }).click(); +``` + +### Checkbox + +```typescript +await page.getByRole("checkbox", { name: "Create Multiple Beds" }).click(); +``` + +### Number Input + +```typescript +await page.getByRole("spinbutton", { name: "PIN Code" }).fill("302020"); +``` + +### Date Input (DD/MM/YYYY fields) + +```typescript +await page.getByPlaceholder("DD", { exact: true }).fill("16"); +await page.getByPlaceholder("MM", { exact: true }).fill("06"); +await page.getByPlaceholder("YYYY", { exact: true }).fill("2009"); +``` + +### Tab Navigation + +```typescript +await page.getByRole("tab", { name: "Age" }).click(); +``` + +## Advanced Selectors (Helper Functions) + +Import from `tests/helper/ui`. These wrap CARE's Radix/shadcn picker components and +handle the popover-vs-drawer, search-debounce, and option-loading quirks for you. + +> **Source of truth:** the complete, current set of helpers (and their exact option +> shapes) is whatever `tests/helper/ui.ts` exports. The selection below covers the +> common cases — open that file to confirm signatures rather than trusting this list. + +### Command Selector (User picker, Service picker) + +```typescript +import { selectFromCommand } from "tests/helper/ui"; + +const trigger = page.getByRole("combobox", { name: "Practitioner" }); +await selectFromCommand(page, trigger, { search: "doctor", itemIndex: 0 }); +``` + +### ValueSet Selector (Codes, body sites, diagnostic codes) + +```typescript +import { selectFromValueSet } from "tests/helper/ui"; + +const trigger = page.getByRole("combobox", { name: "Body Site" }); +await selectFromValueSet(page, trigger, { search: "deltoid", itemIndex: 0 }); +``` + +### Requirements Selector (Multi-select with Plus buttons) + +```typescript +import { selectFromRequirements } from "tests/helper/ui"; + +const trigger = page.getByRole("combobox", { name: "Specimen Requirements" }); +await selectFromRequirements(page, trigger, { search: "blood", itemIndex: 0 }); +``` + +### Location Multi-Select + +```typescript +import { selectFromLocationMultiSelect } from "tests/helper/ui"; + +const trigger = page.getByRole("button", { name: "Select Locations" }); +await selectFromLocationMultiSelect(page, trigger, { + search: "Ward", + itemIndex: 0, + closeAfterSelect: true, +}); +``` + +### Category Picker (Hierarchical navigation) + +```typescript +import { selectFromCategoryPicker } from "tests/helper/ui"; + +const trigger = page.getByRole("combobox", { name: "Activity" }); +await selectFromCategoryPicker(page, trigger, { + navigateCategories: ["Lab Tests", "Blood Tests"], + itemIndex: 0, +}); +``` + +There is also `selectFromDefinitionCategoryPicker` for activity/resource definitions. + +### Filter Select + +```typescript +import { selectFromFilterSelect } from "tests/helper/ui"; + +await selectFromFilterSelect(page, /status/i, "active"); +``` + +### Tab or Menu Item (responsive) + +Handles layouts where tabs collapse into a "More" dropdown on narrow viewports. + +```typescript +import { clickTabOrMenuItem } from "tests/helper/ui"; + +await clickTabOrMenuItem(page, /service requests/i); +``` + +## Assertions + +### Toast Notifications + +```typescript +import { expectToast } from "tests/helper/ui"; + +await expectToast(page, "Location Created"); +await expectToast(page, /created successfully/i); +await expectToast(page, "Saved", { timeout: 15000 }); // custom timeout +``` + +### Form Field Errors + +```typescript +import { getFieldErrorMessage } from "tests/helper/error"; + +const nameField = page.getByRole("textbox", { name: "Name" }); +await expect(getFieldErrorMessage(nameField)).toContainText("This field is required"); +``` + +### Table Content + +```typescript +const tableBody = page.locator('[data-slot="table-body"]'); +await expect(tableBody).toContainText("expected text"); + +// Click a row +await page.locator('[data-slot="table-body"] tr').first().click(); + +// Find a specific row +await page.getByRole("row").filter({ hasText: departmentName }).click(); +``` + +### Table Badges + +```typescript +import { verifyTableBadges } from "tests/helper/ui"; + +await verifyTableBadges(page, "Active", "My Item Name"); +``` + +### Visibility & Values + +```typescript +await expect(element).toBeVisible(); +await expect(element).toBeVisible({ timeout: 10000 }); +await expect(element).not.toBeVisible(); + +await expect(element).toHaveValue("expected value"); +await expect(element).toContainText("partial text"); +await expect(element).toBeDisabled(); +await expect(element).toBeEnabled(); +``` + +### `data-slot` Selectors + +Components expose `data-slot` attributes (part of shadcn/ui's architecture — **not** +added for testing) for stable targeting: + +```typescript +// Table +await expect(page.locator('[data-slot="table-body"]')).toContainText(name); +await page.locator('[data-slot="table-row"]').first().click(); + +// Badge +await expect( + page.locator('[data-slot="badge"]').filter({ hasText: "Active" }), +).toBeVisible(); + +// Collapsible +const card = page.locator('[data-slot="collapsible"]').filter({ hasText: "Lab Tests" }); +await card.locator('[data-slot="collapsible-trigger"]').click(); + +// Command input (search fields) +const input = page.locator('[data-slot="command-input"]').first(); +await input.fill(""); // Clear first +await input.fill(search); // Then fill +``` + +## Buttons and Actions + +```typescript +// Submit / Create +await page.getByRole("button", { name: "Create" }).click(); +await page.getByRole("button", { name: "Save" }).click(); +await page.getByRole("button", { name: "Submit" }).click(); + +// Edit +await page.locator("button[title='Edit Location']").first().click(); +await page.getByRole("button", { name: /Edit/i }).click(); + +// Cancel +await page.getByRole("button", { name: "Cancel" }).click(); +``` + +## Navigation + +```typescript +// URL navigation +await page.goto(`/facility/${facilityId}/settings/locations`); + +// Sidebar +await page.getByRole("button", { name: "Toggle Sidebar" }).click(); +await page.getByRole("button", { name: "Patients", exact: true }).click(); + +// Wait for navigation — prefer asserting a visible element (auto-waits) +await expect(page.getByRole("heading", { name: "Facility Overview" })).toBeVisible(); +await page.waitForURL(/\/facility\/[^/]+\/overview$/); +``` + +--- + +# Pattern Gallery + +### API Response Verification on Form Submit + +```typescript +await test.step("Submit and verify API response", async () => { + const responsePromise = page.waitForResponse( + (resp) => + resp.url().includes("/api/v1/facility/") && resp.request().method() === "POST", + ); + await page.getByRole("button", { name: "Create" }).click(); + const response = await responsePromise; + expect(response.status()).toBe(201); +}); +``` + +### `Promise.all()` for Parallel Navigation + API Wait + +The wait promise MUST be listed first so the response listener is registered before +the action that triggers the request — otherwise the request can fire before +Playwright is listening, causing a flaky timeout. + +```typescript +await Promise.all([ + page.waitForResponse( + (resp) => + resp.url().includes("/medication/prescription/") && resp.status() === 200, + ), + page.getByRole("tab", { name: "Medicines" }).click(), +]); +``` + +### Page Load Verification After Navigation + +```typescript +// Prefer asserting a visible element directly (auto-waits up to the global timeout) +await expect(page.getByRole("heading", { name: "Facility Overview" })).toBeVisible(); + +// Only use networkidle as a LAST RESORT when there is no specific element to wait +// for. It is flaky with polling/websockets. (See Critical Rule #10 in SKILL.md.) +// await page.waitForLoadState("networkidle"); +``` + +### Eventually-Consistent Assertions (`expect.toPass()`) + +For UI that updates asynchronously (e.g., a list refreshes after creation): + +```typescript +await expect(async () => { + await expect(page.getByRole("table").getByText(createdName)).toBeVisible(); +}).toPass({ timeout: 10000 }); +``` + +### Parallel vs Serial Execution + +```typescript +// DEFAULT: Tests run in parallel and are independent — prefer this always +test.describe("Location validations", () => { + test("shows error for empty name", async ({ page }) => { /* ... */ }); + test("shows error for duplicate slug", async ({ page }) => { /* ... */ }); +}); + +// AVOID: serial creates hidden dependencies and increases flakiness. +// Only use when a single logical flow MUST share state. Consider making it ONE test +// with multiple steps instead. +test.describe("Location CRUD", () => { + test.describe.configure({ mode: "serial" }); + test("create location", async ({ page }) => { /* ... */ }); + test("edit location", async ({ page }) => { /* ... */ }); +}); +``` + +### Helper Function Extraction Pattern + +Extract reusable form logic into local helpers that return the generated data: + +```typescript +import type { Page } from "@playwright/test"; + +async function createEntity(page: Page, options: { name: string; type: string }) { + await page.getByRole("button", { name: "Create" }).click(); + await page.getByRole("textbox", { name: "Name" }).fill(options.name); + await page.getByRole("combobox", { name: "Type" }).click(); + await page.getByRole("option", { name: options.type }).first().click(); + await page.getByRole("button", { name: "Submit" }).click(); + return options; // Return so the test can assert against it +} +``` + +### Cascading/Dependent Form Elements + +Handle comboboxes that appear based on a previous selection: + +```typescript +let previousCount = 0; +const MAX_CASCADES = 10; + +for (let iteration = 0; iteration < MAX_CASCADES; iteration++) { + const comboboxes = page.getByRole("combobox"); + const count = await comboboxes.count(); + if (count === previousCount) break; + await comboboxes.nth(count - 1).click(); + await page.getByRole("option").first().click(); + previousCount = count; + + if (iteration === MAX_CASCADES - 1) { + throw new Error(`Cascading comboboxes exceeded ${MAX_CASCADES} iterations — possible loop`); + } +} +``` + +### Dismiss Stray Popovers + +```typescript +import { closeAnyOpenPopovers } from "tests/helper/ui"; +await closeAnyOpenPopovers(page); +``` + +### File Upload & Camera + +```typescript +// Standard file upload +const fileInput = page.locator('input[type="file"]'); +await fileInput.setInputFiles("tests/fixtures/sample_file.xlsx"); + +// Camera/image capture (mimic by uploading an image file — no real camera needed) +await fileInput.setInputFiles("tests/fixtures/images/test-image.jpg"); +``` + +Always verify the uploaded file name/thumbnail appears AND the upload API returns success. + +### Delete with Double Confirmation + +Destructive actions typically require two confirmations. Verify BOTH: + +```typescript +await test.step("Delete with double confirmation", async () => { + await page.getByRole("button", { name: "Delete" }).click(); + + await expect(page.getByRole("alertdialog")).toBeVisible(); + await page.getByRole("button", { name: "Confirm" }).click(); + + // Second confirmation (if present — e.g., type the entity name) + await expect(page.getByRole("alertdialog")).toBeVisible(); + await page.getByRole("textbox").fill("confirm"); + await page.getByRole("button", { name: "Delete" }).click(); + + await expectToast(page, /deleted successfully/i); + await expect(page.getByText(entityName)).not.toBeVisible(); +}); +``` + +### Network Mocking (`page.route()`) + +Use sparingly — prefer real backend responses. Only mock when testing error UI that +is hard to trigger naturally. + +```typescript +// Mock a 500 error +await page.route("**/api/v1/facility/*/", (route) => + route.fulfill({ status: 500, body: JSON.stringify({ detail: "Server Error" }) }), +); + +// Mock a network timeout +await page.route("**/api/v1/facility/*/", (route) => route.abort("timedout")); + +// Remove the mock afterwards +await page.unroute("**/api/v1/facility/*/"); +``` + +### Multiple User Roles in a Single Test + +```typescript +import { expect, test } from "@playwright/test"; +import { getFacilityId } from "tests/support/facilityId"; + +test("Admin assigns task, nurse sees it", async ({ browser }) => { + const facilityId = getFacilityId(); + + const adminContext = await browser.newContext({ storageState: "tests/.auth/user.json" }); + try { + const adminPage = await adminContext.newPage(); + await test.step("Admin creates assignment", async () => { + await adminPage.goto(`/facility/${facilityId}/...`); + }); + } finally { + await adminContext.close(); + } + + const nurseContext = await browser.newContext({ storageState: "tests/.auth/nurse.json" }); + try { + const nursePage = await nurseContext.newPage(); + await test.step("Nurse verifies assignment", async () => { + await nursePage.goto(`/facility/${facilityId}/...`); + await expect(nursePage.getByText("Assigned Task")).toBeVisible(); + }); + } finally { + await nurseContext.close(); + } +}); +``` + +Use the `{ browser }` fixture (not `{ page }`) when switching users, and always close +each context in `finally`. + +### Keep Tests Independent + +- Each test MUST be runnable in isolation — never depend on another test's side effects. +- If a test needs precondition data, create it in `beforeEach`/`beforeAll` or via a + direct API call — never assume a previous test created it. +- **Encounter limit awareness** — a patient can have only 5 live encounters at a + time. If your test creates or selects encounters, mark them completed (via API or + UI) after use, or re-runs will flake once the limit is hit. + +--- + +# Test Setup Beyond the UI + +### Direct API Calls with `fetch()` + +Quick and dependency-free. Good for one-off precondition data. + +```typescript +import { getFacilityId } from "tests/support/facilityId"; +import { getApiHeaders, getApiUrl } from "tests/helper/utils"; + +async function createAccountViaApi() { + const facilityId = getFacilityId(); + const res = await fetch(`${getApiUrl()}/api/v1/facility/${facilityId}/account/`, { + method: "POST", + headers: getApiHeaders(), + body: JSON.stringify({ name: "Test Account", type: "income" }), + }); + if (!res.ok) throw new Error(`Failed to create account: ${res.status}`); + return res.json(); +} + +test.beforeEach(async () => { + const account = await createAccountViaApi(); // never call at module top level +}); +``` + +### Preferred: Playwright's `request` Fixture + +`request` (an `APIRequestContext`) is the idiomatic alternative to raw `fetch()`. It +participates in tracing, reuses Playwright's networking, and gives ergonomic +assertions via `expect(response)`. Prefer it for new setup helpers. + +```typescript +import { expect, test } from "@playwright/test"; +import { getFacilityId } from "tests/support/facilityId"; +import { getApiHeaders, getApiUrl } from "tests/helper/utils"; + +test("creates account via API and verifies in UI", async ({ page, request }) => { + const facilityId = getFacilityId(); + + const res = await request.post( + `${getApiUrl()}/api/v1/facility/${facilityId}/account/`, + { headers: getApiHeaders(), data: { name: "Test Account", type: "income" } }, + ); + await expect(res).toBeOK(); + const account = await res.json(); + + await page.goto(`/facility/${facilityId}/settings/billing`); + await expect(page.getByText(account.name)).toBeVisible(); +}); +``` + +--- + +# Recommended Patterns from the Broader Playwright Ecosystem + +These were not in the original CARE guide; adopt them as CARE grows. They mirror the +[currents-dev best-practices skill](https://github.com/currents-dev/playwright-best-practices-skill). + +### Custom Fixtures (reduce `beforeEach` boilerplate) + +Most CARE tests repeat `test.use({ storageState })` + `getFacilityId()` + `goto`. A +custom fixture centralizes that and makes intent explicit. Define once (e.g. +`tests/helper/fixtures.ts`) and import `test`/`expect` from it: + +```typescript +import { test as base, expect } from "@playwright/test"; +import { getFacilityId } from "tests/support/facilityId"; + +export const test = base.extend<{ facilityId: string }>({ + // Run every test in this file as admin + storageState: "tests/.auth/user.json", + facilityId: async ({}, use) => { + await use(getFacilityId()); + }, +}); + +export { expect }; +``` + +```typescript +// In the spec — no beforeEach needed for the common case +import { expect, test } from "tests/helper/fixtures"; + +test("opens the locations page", async ({ page, facilityId }) => { + await page.goto(`/facility/${facilityId}/settings/locations`); + await expect(page.getByRole("heading", { name: /locations/i })).toBeVisible(); +}); +``` + +Migrate incrementally — the existing `beforeEach` + `getFacilityId()` pattern stays +valid; reach for a fixture when several specs share the same setup. + +### Accessibility Smoke Checks (axe-core) + +The mindset says "if a screen reader can't reach it, neither should your test." +`@axe-core/playwright` turns that into a concrete check on key pages. (Requires +adding the dev dependency — alert a maintainer before relying on it in CI.) + +```typescript +import AxeBuilder from "@axe-core/playwright"; +import { expect, test } from "@playwright/test"; + +test("locations page has no critical a11y violations", async ({ page }) => { + await page.goto(`/facility/${getFacilityId()}/settings/locations`); + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa"]) + .analyze(); + const serious = results.violations.filter((v) => + ["serious", "critical"].includes(v.impact ?? ""), + ); + expect(serious).toEqual([]); +}); +``` + +### Assert No Console Errors During a Journey + +Catches uncaught exceptions and failed requests that don't surface in the UI. + +```typescript +test("registration flow logs no console errors", async ({ page }) => { + const errors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + page.on("pageerror", (err) => errors.push(err.message)); + + await page.goto(`/facility/${getFacilityId()}/patient/registration`); + // ... drive the flow ... + + expect(errors, `Console errors:\n${errors.join("\n")}`).toEqual([]); +}); +``` + +### Clock Mocking (`page.clock`) + +For time-dependent UI (token refresh runs every 5 min, relative timestamps, expiry +banners) make time deterministic instead of waiting. + +```typescript +test("shows session-expiry warning after inactivity", async ({ page }) => { + await page.clock.install({ time: new Date("2026-01-01T10:00:00") }); + await page.goto("/"); + await page.clock.fastForward("06:00"); // jump 6 minutes + await expect(page.getByText(/session/i)).toBeVisible(); +}); +``` + +### Deliberately Out of Scope + +The reference skill covers many topics CARE intentionally does **not** need. Don't +add patterns for these unless the app's stack changes: framework-specific testing +(Angular/Vue/Next), Electron/desktop, browser extensions, GraphQL, canvas/WebGL, +service workers, Docker, and multi-provider CI (GitLab/CircleCI/Azure/Jenkins). +CARE runs Chromium-only against a React app on GitHub Actions. + +--- + +# File Organization — Mirror the App's Navigation + +Test directory structure MUST mirror the **application's** route/navigation hierarchy +— not whatever `tests/` happens to contain today. Existing test dirs can lag or drift +from the app; the routes are the source of truth. + +> **Source of truth:** top-level routes live in `src/Routers/routes/` (`adminRoutes.tsx`, +> `FacilityRoutes.tsx`, `OrganizationRoutes.tsx`, `PatientRoutes.tsx`, …), and the +> facility **settings** sub-nav is defined in `src/pages/Facility/settings/layout.tsx`. +> Create a test directory when the app gains a page, even before tests exist for it. +> The tree below is a representative snapshot — confirm against those files. + +``` +tests/ + auth/ # /login, /session, /homepage + profile/ # /profile/* + admin/ # /admin/* + questionnaire/ valueset/ tags/ patientIdentifierConfig/ apps/ + rbac/ -> roles/ permissions/ # /admin/rbac/{roles,permissions} + organization/ # /organization/* + user/ facility/ patient/encounter/ + facility/ # /facility/:id/* + overview/ appointments/ encounters/ queues/ resource/ billing/ users/ + services/locations/inventory/ # /facility/:id/services + locations + settings/ # /facility/:id/settings/* (settings/layout.tsx) + general/ departments/ locations/ devices/ specimenDefinitions/ + observationDefinition/ activityDefinition/ services/ billing/discount/ + chargeItemDefinition/ productKnowledge/ product/ tokenCategory/ + patient/ # /facility/:id/patient/* + patientRegistration.spec.ts + patientHome/ + patientDetails/ -> files/ notes/ request/ users/ + encounter/ -> careTeam/ files/drawings/ forms/enableWhen/ medicine/ + notes/ serviceRequests/ structuredQuestions/ +``` + +The facility `settings/` dir names are camelCased versions of the app's snake_case +route segments (e.g. `healthcare_services` → `services/`, `specimen_definitions` → +`specimenDefinitions/`, `token_category` → `tokenCategory/`). Shared facility-UI +component tests with no single route may live in a `facility/components/` dir. + +**Naming conventions:** +- Directories: camelCase matching the feature (e.g., `activityDefinition/`, + `patientDetails/`) — not kebab-case or snake_case, even though the app routes are. +- Files: `featureAction.spec.ts` (e.g., `locationCreation.spec.ts`, `deviceEdit.spec.ts`). +- Group CRUD operations in the same directory with separate files per action. + +# Common Pitfalls + +1. **Missing `exact: true`** — `{ name: "Status" }` matches "Operational Status" too. +2. **Missing `.first()`** — multiple matching elements cause "strict mode violation". +3. **Hardcoded entity names** — will fail on re-run; always use faker. +4. **Not awaiting helpers** — all helper functions are async; you must `await` them. +5. **Forgetting `test.use({ storageState })`** — tests fail with auth errors. +6. **Not using `test.step()`** — makes reports hard to read. +7. **Reaching for `networkidle`** — do NOT use `page.waitForLoadState("networkidle")` + as your default wait. Prefer asserting a specific element (`expect(locator) + .toBeVisible()`) or `page.waitForResponse(...)`. `networkidle` is flaky with + polling/websockets and never resolves on pages with background activity. Use it + only as a documented last resort. (This is Critical Rule #10 in `SKILL.md`.) +8. **Non-camelCase directory names** — see naming conventions above. + +# Available Constants + +```typescript +import { BODY_SITES, KNOWN_USERNAMES } from "tests/helper/commonConstants"; +``` + +- `BODY_SITES` — array of SNOMED body-site display names for `selectFromValueSet`. +- `KNOWN_USERNAMES` — fixture usernames safe to select in pickers. **This file is the + source of truth; do not duplicate the list in docs or tests.** Import it and read + the array rather than hardcoding names, since the set changes as fixtures evolve. + +# Sanctioned Exceptions to the Rules + +The skill's rules are strict on purpose. Two narrow, intentional exceptions: + +- **`waitForTimeout` inside shared helpers** — the picker helpers in `tests/helper/ui.ts` + use a small bounded `waitForTimeout` to absorb debounced search input where there + is no DOM/response signal to await. This is acceptable **only** inside encapsulated + helpers, never in spec files. In specs, always wait for an element or response. +- **CSS/class selectors inside helpers** — `expectToast` targets the toast container + by class (`.toaster.group`) because the toast has no stable role. Encapsulating + such a selector in one helper is fine; specs themselves must use roles, labels, + text, or existing `data-slot` attributes. + +# Running Specific Tests + +```bash +# Single file +npx playwright test tests/facility/settings/locations/locationCreation.spec.ts + +# By grep pattern +npx playwright test -g "creates location with valid name" + +# Single directory +npx playwright test tests/auth/ + +# Headed / interactive / report +npx playwright test --headed tests/auth/login.spec.ts +npx playwright test --ui +npx playwright show-report +``` diff --git a/playwright/SKILL.md b/playwright/SKILL.md index 1668be3..db8e61b 100644 --- a/playwright/SKILL.md +++ b/playwright/SKILL.md @@ -6,6 +6,24 @@ argument-hint: "Describe the test scenario or feature to test" # Playwright E2E Testing for CARE +This skill is the **router**: when to test, the non-negotiable rules, the QA mindset, +and the form-testing checklist. The mechanics — full form-interaction catalog, every +selector helper, assertions, and the pattern gallery — live in the bundled guide. + +> **Always read the bundled Playwright Guide before writing tests:** +> [`PLAYWRIGHT_GUIDE.md`](./PLAYWRIGHT_GUIDE.md) +> (installed at `~/.claude/skills/playwright/PLAYWRIGHT_GUIDE.md`). +> It covers: test template, auth/storage states, form interactions, advanced +> selector helpers (`selectFromCommand`, `selectFromValueSet`, +> `selectFromCategoryPicker`, …), assertions, data generation, custom fixtures, +> accessibility/console-error/clock patterns, file organization, and pitfalls. +> +> **Then study the [`examples/`](./examples) before writing a new spec** — they are +> the canonical shape to copy: +> - [`crudForm.spec.ts`](./examples/crudForm.spec.ts) — create → validate → verify → edit-prefill +> - [`multiRole.spec.ts`](./examples/multiRole.spec.ts) — second user via `browser.newContext()` + a different storage state +> - [`apiSetup.spec.ts`](./examples/apiSetup.spec.ts) — precondition data via `fetch()` and the `request` fixture + ## When to Use - Writing new Playwright test files @@ -37,93 +55,24 @@ npx playwright test --ui # Interactive UI mode npx playwright show-report # View last HTML report ``` -## Complete Reference - -The full guide with all patterns, selectors, helpers, and examples is in the project: - -- [Playwright Guide](../../../tests/PLAYWRIGHT_GUIDE.md) — Complete test writing reference including: - - Test file template and structure - - Authentication and storage states - - Form interactions (text, select, combobox, date, radio, checkbox) - - Advanced selector helpers (`selectFromCommand`, `selectFromValueSet`, `selectFromCategoryPicker`, etc.) - - Assertions (toast, table, form errors, visibility) - - Data generation with faker - - File organization conventions - - Common pitfalls - -**Always read the Playwright Guide before writing tests.** - ## File Organization — Mirror the UI Navigation -Test directory structure MUST mirror the application's navigation hierarchy. Match the sidebar/URL structure: - -``` -tests/ - auth/ # /login, /session, /homepage - admin/ # /admin/* - organizations/ # /admin/organizations - roles/ # /admin/roles - valueset/ # /admin/valueset - questionnaire/ # /admin/questionnaire - tags/ # /admin/tags - patientIdentifierConfig/ # /admin/patient-identifier-config - billing/ # /billing (top-level billing) - organization/ # /organization/* - user/ # /organization/.../users - facility/ # /organization/.../facilities - patient/encounter/ # /organization/.../patient/encounter - facility/ # /facility/:id/* - billing/ # /facility/:id/billing - components/ # Shared facility UI components - queues/ # /facility/:id/queues - services/locations/inventory/ # /facility/:id/services/locations/inventory - settings/ # /facility/:id/settings/* - activityDefinition/ # settings > activity definitions - billing/discount/ # settings > billing > discounts - chargeItemDefinition/ # settings > charge items - departments/ # settings > departments - devices/ # settings > devices - general/ # settings > general - locations/ # settings > locations - observationDefinition/ # settings > observations - product/ # settings > products - productKnowledge/ # settings > product knowledge - specimenDefinitions/ # settings > specimens - tokenCategory/ # settings > token categories - patient/ # /facility/:id/patient/* - patientRegistration.spec.ts # patient registration - patientHome/ # patient home/list - patientDetails/ # patient profile tabs - files/ # files tab - notes/ # notes tab - request/ # requests tab - users/ # users tab - encounter/ # encounter context - careTeam/ # care team - files/drawings/ # drawings - forms/enableWhen/ # questionnaire forms - medicine/ # prescriptions - notes/ # encounter notes - serviceRequests/ # service requests - structuredQuestions/ # allergy, diagnosis, symptoms, etc. - profile/ # /profile/* -``` - -**Naming conventions:** -- Directories: camelCase matching the feature (e.g., `activityDefinition/`, `patientDetails/`) -- Files: `featureAction.spec.ts` (e.g., `locationCreation.spec.ts`, `deviceEdit.spec.ts`, `departmentUserManage.spec.ts`) — camelCase, not PascalCase -- Group CRUD operations in the same directory with separate files per action +Test directory structure MUST mirror the app's navigation hierarchy (sidebar/URL). +For example `tests/facility/settings/locations/` for `/facility/:id/settings/locations`, +`tests/admin/roles/` for `/admin/roles`. The full tree is in the guide. -## Quick Reference +- Directories: **camelCase** matching the feature (`activityDefinition/`, `patientDetails/`). +- Files: `featureAction.spec.ts` (e.g., `locationCreation.spec.ts`, `deviceEdit.spec.ts`). +- Group CRUD operations in the same directory with separate files per action. -### Test Naming Convention +## Test Naming Convention -- Name describes the **expected outcome**, not the action: `"shows error when name is empty"` not `"test empty name"` -- Use format: `" when "` or `" for "` -- Must be greppable — avoid generic words like "test", "works", "correct" -- Examples: `"creates location with all fields"`, `"rejects duplicate department name"`, `"displays prefilled data on edit form"` +- Name the **expected outcome**, not the action: `"shows error when name is empty"`, + not `"test empty name"`. +- Format: `" when "` or `" for "`. +- Must be greppable — avoid generic words like "test", "works", "correct". -### Test File Structure +## Canonical Test Structure ```typescript import { faker } from "@faker-js/faker"; @@ -144,7 +93,6 @@ test.describe("Feature Name", () => { await test.step("Fill location form", async () => { // actions }); - await test.step("Verify location appears in list", async () => { // assertions }); @@ -152,425 +100,116 @@ test.describe("Feature Name", () => { }); ``` -### Auth Storage States - -| Storage State | Role | Username | -|---|---|---| -| `tests/.auth/user.json` | Admin | `admin` | -| `tests/.auth/facilityAdmin.json` | Facility Admin | `care-fac-admin` | -| `tests/.auth/nurse.json` | Nurse | `care-nurse` | - -These files are generated (gitignored) by setup specs in `tests/setup/*.setup.ts`. If you hit a missing storage-state file error, run the auth setup first (for example: `npx playwright test tests/setup/auth.setup.ts`). - -### Key Helpers (from `tests/helper/ui`) - -- `expectToast(page, "message")` — Assert toast notification -- `selectFromCommand(page, trigger, { search, itemIndex })` — User/service picker -- `selectFromValueSet(page, trigger, { search, itemIndex })` — Code/body site picker -- `selectFromFilterSelect(page, /label/i, "value")` — Filter select -- `getFieldErrorMessage(locator)` — Get form field error (from `tests/helper/error`) - -### Critical Rules - -1. **Use `faker` for data you create in tests** (entity names, notes, and random array selection for generated values). For selecting existing fixture-backed options (usernames, body sites, etc.), use shared constants in `tests/helper/commonConstants.ts` — avoid scattered hardcoded literals -2. **Always use deterministic fixture IDs** — use `getFacilityId()`, `getPatientId()`, `getEncounterId()` from `tests/support/` for navigation. NEVER select a random encounter/patient/facility from a list in the UI — random selection causes flakiness when data changes between runs -3. Always include `test.use({ storageState })` for authenticated flows (omit only for explicit public/auth-page tests) -4. Use `exact: true` on selectors when partial matches are possible -5. **`.first()` only after searching/filtering** — use `.first()` only when you've searched a list and are selecting from filtered results. Never use it to randomly pick from an unfiltered list -6. Use `test.step()` to organize test actions -7. Place tests in matching feature directory under `tests/` -8. **Verify API responses on form submission** — when feasible, wait for and assert the API response (status code) using `page.waitForResponse()`. Skip if the form triggers multiple chained calls or the response isn't meaningful to assert -9. **Verify page navigation** — after navigating to a new page, assert a heading or unique element is visible to confirm the page fully loaded -10. **Wait for specific UI indicators over hardcoded timeouts** — prefer waiting for a visible element or API response instead of arbitrary `{ timeout: N }` values. Use `page.waitForLoadState("networkidle")` only when no better signal exists (it can be flaky with polling/websockets) -11. **Never add custom test IDs to source code** — never add `data-testid` or any custom attribute to the application code for test targeting. Tests must use existing roles, labels, text, accessible selectors, and existing `data-slot` attributes (these are part of shadcn/ui's component architecture, not added for testing) -12. **Verify in-page generated content** — when something is generated without page change (QR codes, tokens, etc.), always assert both element visibility AND the corresponding API request/response -13. **Prefer constants over duplication** — extract reusable values (known usernames, body sites, common options) into shared constants in `tests/helper/` to avoid duplicates across tests -14. **Verify locally before pushing** — always run the specific test file(s) locally with `npx playwright test ` and confirm they pass before pushing code -15. **3-strike rule: ask for human help** — if a test keeps failing after 3 AI fix attempts, stop and ask the user to manually inspect the UI. The AI may be missing a visual detail, animation, or dynamic behavior that only a human can verify -16. **Do not touch CI/CD YAML** — never modify GitHub Actions workflow files or CI configuration unless explicitly asked to do so. If a test requires a new `.env` variable that works locally, ALERT the human that CI/CD YAML may need updating for it to work in pipelines. -17. **Never use `test.skip()` or conditional skips to hide failures** — if a test is failing, fix it. Never add `.skip()`, `test.fixme()`, or `if` conditions to bypass a failure. These create false positives where the suite appears green but functionality is broken. Only use `.skip()` for genuinely unsupported environments (e.g., OS-specific) with a clear comment explaining why. -18. **Continuously improve this skill** — capture improvements as follow-up changes through a separate human-reviewed PR; do not self-modify the skill during normal test generation. +**Auth storage states:** `tests/.auth/user.json` (admin), `tests/.auth/facilityAdmin.json` +(facility admin), `tests/.auth/nurse.json` (nurse). Generated by `tests/setup/*.setup.ts`. +**Key helpers** (from `tests/helper/ui`): `expectToast`, `selectFromCommand`, +`selectFromValueSet`, `selectFromFilterSelect`, plus `getFieldErrorMessage` (from +`tests/helper/error`). See the guide for full signatures and examples. + +## Critical Rules + +1. **Use `faker` for data you create** (entity names, notes, random array selection). + For selecting **existing** fixture-backed options (usernames, body sites), import + shared constants from `tests/helper/commonConstants.ts` — never scatter literals. +2. **Always use deterministic fixture IDs** — `getFacilityId()`, `getPatientId()`, + `getEncounterId()` from `tests/support/`. NEVER select a random + encounter/patient/facility from a UI list — random selection flakes when data changes. +3. Always include `test.use({ storageState })` for authenticated flows (omit only for + explicit public/auth-page tests). +4. Use `exact: true` on selectors when partial matches are possible. +5. **`.first()` only after searching/filtering** — never to randomly pick from an + unfiltered list. +6. Use `test.step()` to organize test actions. +7. Place tests in the matching feature directory under `tests/`. +8. **Verify API responses on form submission** — `page.waitForResponse()` + assert + status. Skip only when the form fires multiple chained calls or the response isn't + meaningful. +9. **Verify page navigation** — after navigating, assert a heading or unique element + is visible to confirm the page loaded. +10. **Wait for specific UI indicators over hardcoded timeouts** — prefer a visible + element or API response over arbitrary `{ timeout: N }`. Avoid + `page.waitForLoadState("networkidle")`; use it only as a documented last resort + (it's flaky with polling/websockets). +11. **Never add custom test IDs to source code** — no `data-testid` or any custom + attribute for test targeting. Use existing roles, labels, text, and existing + `data-slot` attributes (part of shadcn/ui, not added for testing). +12. **Verify in-page generated content** — for QR codes, tokens, etc., assert both + element visibility AND the corresponding API request/response. +13. **Prefer constants over duplication** — extract reusable values into shared + constants in `tests/helper/`. +14. **Verify locally before pushing** — run the specific file(s) with + `npx playwright test ` and confirm they pass before pushing. +15. **3-strike rule** — if a test keeps failing after 3 fix attempts, stop and ask a + human to inspect the UI. You may be missing a visual/animation/dynamic detail. +16. **Do not touch CI/CD YAML** — never modify GitHub Actions/CI unless explicitly + asked. If a test needs a new `.env` var that works locally, ALERT the human that + CI YAML may need updating. +17. **Never use `test.skip()`/`test.fixme()`/conditional skips to hide failures** — + fix the test instead. `.skip()` is only for genuinely unsupported environments + (e.g., OS-specific) with a clear comment. +18. **Continuously improve this skill** — capture improvements as a separate + human-reviewed PR; do not self-modify the skill during normal test generation. + +> Two narrow **sanctioned exceptions** to rules #10/#11: a bounded `waitForTimeout` +> and CSS/class selectors are acceptable **inside shared helpers** (e.g. debounced +> search, `expectToast`'s toast container) — never in spec files. See the guide. ## Mindset: Senior QA Engineer -When writing tests, think as a senior QA engineer — question every decision, challenge every assumption, and choose the best approach possible: +Question every decision, challenge every assumption, choose the best approach. -- **Test user journeys, not implementation** — focus on what the user sees and does, not internal state -- **Cover edge cases** — empty states, boundary values, error states, permission denied, network failures -- **Test both happy path and unhappy path** — form validation errors, unauthorized access, concurrent operations -- **Always include negative tests** — submit forms with missing required fields, enter invalid data formats, attempt actions without permissions, test API error responses (4xx/5xx), verify proper error messages are shown to the user -- **Assert what matters** — verify the outcome the user cares about, not incidental DOM structure -- **Think about test isolation** — each test must be independent; never rely on another test's side effects -- **Consider accessibility** — if a screen reader can't reach it, neither should your test (use roles, labels, not CSS selectors) -- **Regression-first** — when fixing a bug, write the test that would have caught it before fixing the code -- **Data boundaries** — test with minimum valid input, maximum valid input, and just-beyond-boundary invalid input -- **State transitions** — verify the UI correctly reflects state changes (loading → loaded, enabled → disabled, empty → populated) +- **Test user journeys, not implementation** — what the user sees and does. +- **Cover edge cases** — empty states, boundaries, error states, permission denied, + network failures. +- **Always include negative tests** — missing required fields, invalid formats, + unauthorized access, 4xx/5xx API responses; verify the error message shown. +- **Assert what matters** — the outcome the user cares about, not incidental DOM. +- **Test isolation** — each test independent; never rely on another's side effects. +- **Accessibility** — if a screen reader can't reach it, neither should your test + (use roles/labels, not CSS). The guide has an axe-core smoke pattern. +- **Regression-first** — when fixing a bug, write the test that would have caught it + before fixing the code. +- **Data boundaries** — minimum valid, maximum valid, and just-beyond-boundary invalid. +- **State transitions** — verify loading → loaded, enabled → disabled, empty → populated. -### Form Testing Checklist +## Form Testing Checklist Every form MUST have tests covering: -1. **Required field validation** — submit with all fields empty, verify each required field shows an error -2. **All fields filled (happy path)** — submit with all valid data, verify success -3. **Field combinations** — submit with only some required fields filled, verify correct errors appear for missing ones -4. **Error message validation** — assert exact error text matches for each validation rule (min length, format, required, etc.) -5. **Individual field update (edit forms)** — verify editing one field doesn't corrupt other fields. Test the full edit flow; don't reload per field (that's for unit tests) -6. **Field-level validation** — test invalid formats (email, phone, date), boundary values (min/max length), special characters -7. **Form reset/cancel** — verify cancel doesn't save, form resets properly -8. **Duplicate submission prevention** — verify button disables after click, no double-submit -9. **Post-submission verification (mandatory)** — after every successful form submit, ALWAYS verify ALL of these: - - Toast notification appears with correct message - - URL/path changes to the expected destination - - The newly created/updated data is visible on the redirected page (card, table row, detail view) - - Never stop at just the submit click — the test is incomplete without confirming the data landed correctly -10. **Edit form prefill verification** — when opening an edit form, always assert that existing data is prefilled in all fields before making changes - -## Patterns - -### Debugging Failing Tests +1. **Required field validation** — submit all-empty, verify each required field errors. +2. **All fields filled (happy path)** — submit valid data, verify success. +3. **Field combinations** — only some required fields filled, verify correct errors. +4. **Error message validation** — assert exact error text per rule (min length, + format, required). +5. **Individual field update (edit forms)** — editing one field doesn't corrupt others. +6. **Field-level validation** — invalid formats (email/phone/date), boundary values, + special characters. +7. **Form reset/cancel** — cancel doesn't save; form resets. +8. **Duplicate submission prevention** — button disables after click; no double-submit. +9. **Post-submission verification (mandatory)** — after every successful submit, verify + ALL of: toast with correct message, URL/path changes to the expected destination, + AND the new/updated data is visible on the redirected page. Never stop at the click. +10. **Edit form prefill verification** — assert existing data is prefilled in all + fields before making changes. + +## Debugging & Flakiness Triage ```bash -# Run with trace viewer (captures screenshots, network, console) -npx playwright test tests/path/to/test.spec.ts --trace on - -# Run in debug mode (step through with Playwright Inspector) -npx playwright test tests/path/to/test.spec.ts --debug - -# Run headed to visually see what's happening -npx playwright test tests/path/to/test.spec.ts --headed - -# View HTML report from last run -npx playwright show-report - -# Open a trace zip directly (if available) +npx playwright test --trace on # trace viewer (screenshots, network, console) +npx playwright test --debug # step through with the Inspector +npx playwright test --headed # watch it run +npx playwright show-report # last HTML report npx playwright show-trace test-results//trace.zip ``` -When a test fails: -1. Check the HTML report (`npx playwright show-report`) for screenshots and trace -2. Run with `--headed` to visually observe the failure -3. Use `--debug` to step through interactively -4. Check if DB state is stale — run `npm run playwright:db-restore` +When a test fails: check the HTML report → run `--headed` → use `--debug` → if data +looks stale, `npm run playwright:db-restore`. **Flakiness triage (passes sometimes, fails sometimes):** -- **Timing/race condition** → element appears before data loads. Fix: wait for API response or specific text, not `networkidle` -- **Animation/transition** → click happens mid-animation. Fix: wait for element to be stable (`await locator.waitFor({ state: 'visible' })`) before acting -- **Parallel data collision** → two tests create same faker value. Fix: use `Date.now()` suffix or more specific faker seeds -- **Stale DB state** → previous test left data behind. Fix: `npm run playwright:db-restore` or restructure to not depend on clean state -- **Polling/WebSocket** → `networkidle` never resolves. Fix: wait for specific DOM change instead - -### API Response Verification on Form Submit - -```typescript -await test.step("Submit and verify API response", async () => { - const responsePromise = page.waitForResponse( - (resp) => - resp.url().includes("/api/v1/facility/") && resp.request().method() === "POST", - ); - - await page.getByRole("button", { name: "Create" }).click(); - - const response = await responsePromise; - expect(response.status()).toBe(201); -}); -``` - -### Page Load Verification After Navigation - -```typescript -// After clicking a link or submitting that navigates -// Prefer asserting a visible element directly (auto-waits up to timeout) -await expect( - page.getByRole("heading", { name: "Facility Overview" }), -).toBeVisible(); - -// Only use networkidle as last resort when no specific element to wait for -// await page.waitForLoadState("networkidle"); -``` - -### Parallel vs Serial Execution - -```typescript -// DEFAULT: Tests run in parallel and are independent — prefer this always -test.describe("Location validations", () => { - test("shows error for empty name", async ({ page }) => { /* ... */ }); - test("shows error for duplicate slug", async ({ page }) => { /* ... */ }); -}); - -// AVOID: serial creates hidden dependencies and increases flakiness. -// Only use when a single logical flow MUST share state (rare). -// If you need serial, consider making it ONE test with multiple steps instead. -test.describe("Location CRUD", () => { - test.describe.configure({ mode: "serial" }); - test("create location", async ({ page }) => { /* ... */ }); - test("edit location", async ({ page }) => { /* ... */ }); - test("delete location", async ({ page }) => { /* ... */ }); -}); -``` - -### `.fill()` vs `.pressSequentially()` — IMPORTANT - -- **`.fill()`** clears the field first, then sets the value. Use for fresh input. -- **`.pressSequentially()`** types character by character WITHOUT clearing — appends to existing data. -- AI often confuses these. Be explicit: - -```typescript -// CORRECT: Clear and type fresh value -await page.getByRole("textbox", { name: "Name" }).fill("New Value"); - -// CORRECT: Type along with existing data (e.g., slug auto-generation, search) -await page.getByRole("textbox", { name: "Name" }).pressSequentially("appended text"); - -// WRONG: Using fill() when you want to append -// WRONG: Using pressSequentially() when you want to replace -``` - -### Direct API Calls for Test Setup - -Use `fetch()` for creating precondition data without going through UI: - -```typescript -import { getFacilityId } from "tests/support/facilityId"; -import { getApiHeaders, getApiUrl } from "tests/helper/utils"; - -async function createAccountViaApi() { - const facilityId = getFacilityId(); - const res = await fetch(`${getApiUrl()}/api/v1/facility/${facilityId}/account/`, { - method: "POST", - headers: getApiHeaders(), - body: JSON.stringify({ name: "Test Account", type: "income" }), - }); - if (!res.ok) { - throw new Error(`Failed to create account: ${res.status}`); - } - return res.json(); -} - -// Call inside a test or hook — never at module top level -test.beforeEach(async () => { - const account = await createAccountViaApi(); - // ... use account in the test -}); -``` - -### Promise.all() for Parallel Navigation + API Wait - -The wait promise MUST be listed first so the response listener is registered before the action that triggers the request — otherwise the request can fire before Playwright is listening, causing a flaky timeout. - -```typescript -await Promise.all([ - page.waitForResponse( - (resp) => - resp.url().includes("/medication/prescription/") && - resp.status() === 200, - ), - page.getByRole("tab", { name: "Medicines" }).click(), -]); -``` - -### data-slot Selectors - -Components use `data-slot` attributes for stable targeting: - -```typescript -// Table -await expect(page.locator('[data-slot="table-body"]')).toContainText(name); -await page.locator('[data-slot="table-row"]').first().click(); - -// Badge -await expect(page.locator('[data-slot="badge"]').filter({ hasText: "Active" })).toBeVisible(); - -// Collapsible -const card = page.locator('[data-slot="collapsible"]').filter({ hasText: "Lab Tests" }); -await card.locator('[data-slot="collapsible-trigger"]').click(); - -// Command input (search fields) -const scope = page; -const input = scope.locator('[data-slot="command-input"]').first(); -await input.fill(""); // Clear first -await input.fill(search); // Then fill -``` - -### Helper Function Extraction Pattern - -Extract reusable form logic into local helpers that return generated data: - -```typescript -import type { Page } from "@playwright/test"; - -async function createEntity(page: Page, options: { name: string; type: string }) { - await page.getByRole("button", { name: "Create" }).click(); - await page.getByRole("textbox", { name: "Name" }).fill(options.name); - await page.getByRole("combobox", { name: "Type" }).click(); - await page.getByRole("option", { name: options.type }).first().click(); - await page.getByRole("button", { name: "Submit" }).click(); - return options; // Return so test can assert against it -} -``` - -### Cascading/Dependent Form Elements - -Handle comboboxes that appear based on previous selection: - -```typescript -const region = page; -let previousCount = 0; -const MAX_CASCADES = 10; - -for (let iteration = 0; iteration < MAX_CASCADES; iteration++) { - const comboboxes = region.getByRole("combobox"); - const count = await comboboxes.count(); - if (count === previousCount) break; - const combobox = comboboxes.nth(count - 1); - await combobox.click(); - await page.getByRole("option").first().click(); - previousCount = count; - - if (iteration === MAX_CASCADES - 1) { - throw new Error( - `Cascading comboboxes exceeded ${MAX_CASCADES} iterations - possible loop`, - ); - } -} -``` - -### Phone Number Generation (Indian Format) - -```typescript -const phoneNumber = `${faker.helpers.arrayElement([7, 8, 9])}${faker.string.numeric(9)}`; -``` - -### Dismiss Stray Popovers - -```typescript -import { closeAnyOpenPopovers } from "tests/helper/ui"; -await closeAnyOpenPopovers(page); -``` - -### Eventually-Consistent Assertions (`expect.toPass()`) - -For UI that updates asynchronously (e.g., list refreshes after creation): - -```typescript -await expect(async () => { - await expect(page.getByRole("table").getByText(createdName)).toBeVisible(); -}).toPass({ timeout: 10000 }); -``` - -Use when the data appears after a short delay due to cache invalidation or refetch. - -### File Upload & Camera - -For file upload inputs, use `setInputFiles()`: - -```typescript -// Standard file upload -const fileInput = page.locator('input[type="file"]'); -await fileInput.setInputFiles("tests/fixtures/sample_file.xlsx"); - -// Camera/image capture (mimic by uploading an image file) -await fileInput.setInputFiles("tests/fixtures/images/test-image.jpg"); -``` - -Camera inputs are tested by uploading a fixture image file — no actual camera simulation needed. Always verify: -- The uploaded file name/thumbnail appears in the UI -- The API request for upload returns success - -### Delete with Double Confirmation - -Destructive actions typically require two confirmations. Always verify BOTH: - -```typescript -const entityName = ""; - -await test.step("Delete with double confirmation", async () => { - // First: click delete button - await page.getByRole("button", { name: "Delete" }).click(); - - // First confirmation dialog - await expect(page.getByRole("alertdialog")).toBeVisible(); - await page.getByRole("button", { name: "Confirm" }).click(); - - // Second confirmation (if present — e.g., type entity name) - await expect(page.getByRole("alertdialog")).toBeVisible(); - await page.getByRole("textbox").fill("confirm"); - await page.getByRole("button", { name: "Delete" }).click(); - - // Verify deletion - await expectToast(page, /deleted successfully/i); - await expect(page.getByText(entityName)).not.toBeVisible(); -}); -``` - -Never skip a confirmation step — verify both dialogs appear and require interaction. - -### Network Mocking (`page.route()`) - -Use for testing error states without needing the backend to fail: - -```typescript -// Mock a 500 error -await page.route("**/api/v1/facility/*/", (route) => - route.fulfill({ status: 500, body: JSON.stringify({ detail: "Server Error" }) }), -); - -// Mock network timeout -await page.route("**/api/v1/facility/*/", (route) => route.abort("timedout")); - -// Remove mock after test -await page.unroute("**/api/v1/facility/*/"); -``` - -Use sparingly — prefer real backend responses. Only mock when testing specific error UI that's hard to trigger naturally. - -### Multiple User Roles in a Single Test - -When a workflow requires actions from different users (e.g., admin creates, nurse verifies), use `browser.newContext()` with different storage states: - -```typescript -import { expect, test } from "@playwright/test"; - -import { getFacilityId } from "tests/support/facilityId"; - -test("Admin assigns task, nurse sees it", async ({ browser }) => { - const facilityId = getFacilityId(); - - // Admin context — close in `finally` so a failing step still releases the context - const adminContext = await browser.newContext({ - storageState: "tests/.auth/user.json", - }); - try { - const adminPage = await adminContext.newPage(); - await test.step("Admin creates assignment", async () => { - await adminPage.goto(`/facility/${facilityId}/...`); - // ... admin actions - }); - } finally { - await adminContext.close(); - } - - // Nurse context - const nurseContext = await browser.newContext({ - storageState: "tests/.auth/nurse.json", - }); - try { - const nursePage = await nurseContext.newPage(); - await test.step("Nurse verifies assignment", async () => { - await nursePage.goto(`/facility/${facilityId}/...`); - await expect(nursePage.getByText("Assigned Task")).toBeVisible(); - }); - } finally { - await nurseContext.close(); - } -}); -``` - -Always close each context after use. Use `{ browser }` fixture instead of `{ page }` when switching users. - -### Keep Tests Independent - -- Each test MUST be runnable in isolation — never depend on another test's side effects -- Use `test.describe.configure({ mode: "serial" })` only when tests share a logical CRUD flow (create → edit → delete) within the SAME describe block -- If a test needs precondition data, create it in `beforeEach`/`beforeAll` or via direct API calls — never assume a previous test created it -- Tests in DIFFERENT files must NEVER depend on each other -- **Encounter limit awareness** — a patient can have only 5 live encounters at a time. If your test creates encounters or selects existing ones, ensure you mark them as completed (via API or UI) after use. Failing to do so causes flaky failures when the limit is reached across test runs. +- **Timing/race** → element appears before data loads. Fix: wait for API response or + specific text, not `networkidle`. +- **Animation/transition** → click mid-animation. Fix: `await locator.waitFor({ state: "visible" })` first. +- **Parallel data collision** → two tests create the same faker value. Fix: `Date.now()` + suffix or more specific seeds. +- **Stale DB state** → `npm run playwright:db-restore` or stop depending on clean state. +- **Polling/WebSocket** → `networkidle` never resolves. Fix: wait for a specific DOM change. diff --git a/playwright/examples/apiSetup.spec.ts b/playwright/examples/apiSetup.spec.ts new file mode 100644 index 0000000..d2ed4b0 --- /dev/null +++ b/playwright/examples/apiSetup.spec.ts @@ -0,0 +1,119 @@ +/* + * GOLDEN EXAMPLE — precondition data created via the API, then verified in the UI + * + * REFERENCE ONLY. This whole file is a comment: the skills repo does not install + * Playwright or the `tests/...` modules, so nothing here is imported or compiled. + * Copy the shape into a real spec in care_fe. + * + * Derived from tests/facility/billing/accountTransfer.spec.ts. Shows BOTH ways to + * create setup data without driving the UI: + * 1. raw fetch() + getApiHeaders() — quick, dependency-free (the existing pattern) + * 2. Playwright's `request` fixture — idiomatic, trace-integrated (preferred for new code) + * + * Setup helpers create data in a hook/test body (never at module top level), then the + * test asserts the precondition is reflected in the UI. + * + * --------------------------------------------------------------------------- + * import { faker } from "@faker-js/faker"; + * import { type APIRequestContext, expect, test } from "@playwright/test"; + * + * import { getApiHeaders, getApiUrl } from "tests/helper/utils"; + * import { getFacilityId } from "tests/support/facilityId"; + * import { getPatientId } from "tests/support/patientId"; + * + * interface AccountInfo { + * id: string; + * name: string; + * status: string; + * } + * + * // --- Option 1: raw fetch() (the existing care_fe pattern) ------------------- + * async function createAccountViaFetch( + * facilityId: string, + * patientId: string, + * name: string, + * ): Promise { + * const res = await fetch(`${getApiUrl()}/api/v1/facility/${facilityId}/account/`, { + * method: "POST", + * headers: getApiHeaders(), + * body: JSON.stringify({ + * name, + * status: "active", + * billing_status: "open", + * patient: patientId, + * service_period: { start: new Date().toISOString() }, + * }), + * }); + * if (!res.ok) { + * throw new Error(`Failed to create account: ${res.status} — ${await res.text()}`); + * } + * return (await res.json()) as AccountInfo; + * } + * + * // --- Option 2: the `request` fixture (preferred for new helpers) ------------ + * async function createAccountViaRequest( + * request: APIRequestContext, + * facilityId: string, + * patientId: string, + * name: string, + * ): Promise { + * const res = await request.post( + * `${getApiUrl()}/api/v1/facility/${facilityId}/account/`, + * { + * headers: getApiHeaders(), + * data: { + * name, + * status: "active", + * billing_status: "open", + * patient: patientId, + * service_period: { start: new Date().toISOString() }, + * }, + * }, + * ); + * await expect(res).toBeOK(); // ergonomic assertion built into the fixture + * return (await res.json()) as AccountInfo; + * } + * + * test.use({ storageState: "tests/.auth/user.json" }); + * + * test.describe("Billing accounts — created via API", () => { + * let facilityId: string; + * let patientId: string; + * + * test.beforeEach(() => { + * facilityId = getFacilityId(); + * patientId = getPatientId(); + * }); + * + * test("an account created via fetch() appears in the billing list", async ({ + * page, + * }) => { + * const name = `Acct ${faker.string.alphanumeric(6)}`; + * + * await test.step("Create precondition account via API", async () => { + * await createAccountViaFetch(facilityId, patientId, name); + * }); + * + * await test.step("Verify it shows in the UI", async () => { + * await page.goto(`/facility/${facilityId}/settings/billing`); + * await expect(page.getByText(name)).toBeVisible(); + * }); + * }); + * + * test("an account created via the request fixture appears in the list", async ({ + * page, + * request, + * }) => { + * const name = `Acct ${faker.string.alphanumeric(6)}`; + * + * await test.step("Create precondition account via request fixture", async () => { + * await createAccountViaRequest(request, facilityId, patientId, name); + * }); + * + * await test.step("Verify it shows in the UI", async () => { + * await page.goto(`/facility/${facilityId}/settings/billing`); + * await expect(page.getByText(name)).toBeVisible(); + * }); + * }); + * }); + */ diff --git a/playwright/examples/crudForm.spec.ts b/playwright/examples/crudForm.spec.ts new file mode 100644 index 0000000..e67b79a --- /dev/null +++ b/playwright/examples/crudForm.spec.ts @@ -0,0 +1,115 @@ +/* + * GOLDEN EXAMPLE — CRUD form (create → validate → verify → edit-prefill) + * + * REFERENCE ONLY. This whole file is a comment: the skills repo does not install + * Playwright or the `tests/...` modules, so nothing here is imported or compiled. + * Copy the shape into a real spec in care_fe. + * + * Derived from tests/facility/settings/devices/deviceCreation.spec.ts, polished to + * follow the skill's rules: test.step grouping, waitForResponse on submit (Rule #8), + * no hardcoded timeouts (Rule #10), canonical faker phone-gen, and edit-prefill + * verification (Form Checklist #10). + * + * --------------------------------------------------------------------------- + * import { faker } from "@faker-js/faker"; + * import { expect, test } from "@playwright/test"; + * + * import { getFieldErrorMessage } from "tests/helper/error"; + * import { getFacilityId } from "tests/support/facilityId"; + * + * test.use({ storageState: "tests/.auth/user.json" }); + * + * test.describe("Facility Devices — create", () => { + * let facilityId: string; + * let deviceName: string; + * + * test.beforeEach(async ({ page }) => { + * facilityId = getFacilityId(); + * deviceName = faker.commerce.productName(); + * await page.goto(`/facility/${facilityId}/settings/devices`); + * }); + * + * test("shows required-field error when name is empty", async ({ page }) => { + * await test.step("Open the create form", async () => { + * await page.getByRole("link", { name: "Add Device" }).click(); + * // Wait for a real signal (the Save button), not a fixed timeout + * await expect(page.getByRole("button", { name: "Save" })).toBeVisible(); + * }); + * + * await test.step("Submit without the required Registered Name", async () => { + * // Fill a non-required field so the button is enabled, then submit + * await page + * .getByRole("textbox", { name: "User Friendly Name" }) + * .fill(faker.word.words(2)); + * await page.getByRole("button", { name: "Save" }).click(); + * }); + * + * await test.step("Verify the field-level error", async () => { + * const nameField = page.getByRole("textbox", { name: "Registered Name *" }); + * await expect(getFieldErrorMessage(nameField)).toContainText( + * "This field is required", + * ); + * }); + * }); + * + * test("creates a device with all fields and prefills them on edit", async ({ + * page, + * }) => { + * const userFriendlyName = faker.word.words(2); + * const manufacturer = faker.company.name(); + * const serialNumber = faker.string.alphanumeric(12); + * const phoneNumber = `${faker.helpers.arrayElement([6, 7, 8, 9])}${faker.string.numeric(9)}`; + * + * await test.step("Fill the create form", async () => { + * await page.getByRole("link", { name: "Add Device" }).click(); + * await page + * .getByRole("textbox", { name: "Registered Name *" }) + * .fill(deviceName); + * await page + * .getByRole("textbox", { name: "User Friendly Name" }) + * .fill(userFriendlyName); + * await page.getByRole("textbox", { name: "Manufacturer" }).fill(manufacturer); + * await page.getByRole("textbox", { name: "Serial Number" }).fill(serialNumber); + * await page.getByRole("button", { name: "Add Contact Point" }).click(); + * await page.getByPlaceholder("Enter phone number").first().fill(phoneNumber); + * }); + * + * await test.step("Submit and verify the API response (Rule #8)", async () => { + * const responsePromise = page.waitForResponse( + * (resp) => + * resp.url().includes("/device/") && + * resp.request().method() === "POST" && + * resp.ok(), + * ); + * await page.getByRole("button", { name: "Save" }).click(); + * await responsePromise; + * await expect(page.getByText("Device registered successfully")).toBeVisible(); + * }); + * + * await test.step("Open the device and verify the data landed", async () => { + * await page + * .getByRole("textbox", { name: "Search devices..." }) + * .fill(deviceName); + * await page.getByRole("link", { name: deviceName }).click(); + * await expect( + * page.getByRole("heading", { name: deviceName }), + * ).toBeVisible(); + * await expect(page.getByText(manufacturer)).toBeVisible(); + * await expect(page.getByText(serialNumber)).toBeVisible(); + * }); + * + * await test.step("Edit form prefills existing values (Checklist #10)", async () => { + * await page.getByRole("button", { name: "Edit" }).click(); + * await expect( + * page.getByRole("textbox", { name: "Registered Name *" }), + * ).toHaveValue(deviceName); + * await expect( + * page.getByRole("textbox", { name: "User Friendly Name" }), + * ).toHaveValue(userFriendlyName); + * await expect( + * page.getByRole("textbox", { name: "Serial Number" }), + * ).toHaveValue(serialNumber); + * }); + * }); + * }); + */ diff --git a/playwright/examples/multiRole.spec.ts b/playwright/examples/multiRole.spec.ts new file mode 100644 index 0000000..181773d --- /dev/null +++ b/playwright/examples/multiRole.spec.ts @@ -0,0 +1,111 @@ +/* + * GOLDEN EXAMPLE — multi-user / cross-role flow + * + * REFERENCE ONLY. This whole file is a comment: the skills repo does not install + * Playwright or the `tests/...` modules, so nothing here is imported or compiled. + * Copy the shape into a real spec in care_fe. + * + * Derived from the "multi-user messaging" test in + * tests/facility/patient/encounter/notes/encounterNotes.spec.ts, polished to follow + * the skill: a second user via browser.newContext() with a different storage state, + * the context closed in `finally` (so a failing step still releases it), test.step + * grouping, and waitForResponse to avoid create→list propagation races. + * + * --------------------------------------------------------------------------- + * import { faker } from "@faker-js/faker"; + * import { expect, test } from "@playwright/test"; + * + * import { getEncounterId } from "tests/support/encounterId"; + * import { getFacilityId } from "tests/support/facilityId"; + * import { getPatientId } from "tests/support/patientId"; + * + * // Author this test as the admin; the second user authenticates separately below. + * test.use({ storageState: "tests/.auth/user.json" }); + * + * test("admin posts a note, facility admin sees and replies to it", async ({ + * page, + * browser, + * }) => { + * const facilityId = getFacilityId(); + * const patientId = getPatientId(); + * const encounterId = getEncounterId(); + * const encounterUrl = `/facility/${facilityId}/patient/${patientId}/encounter/${encounterId}`; + * + * const threadTitle = `Thread ${faker.string.alphanumeric(6)}`; + * const adminMessage = `From admin: ${faker.lorem.sentence()}`; + * const facAdminMessage = `From facility admin: ${faker.lorem.sentence()}`; + * + * await test.step("Admin creates a thread and posts the first message", async () => { + * await page.goto(encounterUrl); + * await page.getByRole("tab", { name: "Notes" }).click(); + * + * await page.getByRole("button", { name: "New", exact: true }).click(); + * await page.getByPlaceholder("Enter discussion title...").fill(threadTitle); + * await page.getByRole("button", { name: /Create/i }).click(); + * await expect(page.getByText("Thread created successfully")).toBeVisible(); + * + * await page.getByRole("button").filter({ hasText: threadTitle }).click(); + * await page.getByPlaceholder("Type your message...").fill(adminMessage); + * // Wait promise FIRST so the listener is registered before the click + * await Promise.all([ + * page.waitForResponse( + * (resp) => + * resp.url().includes("/note/") && + * resp.request().method() === "POST" && + * resp.ok(), + * ), + * page.getByRole("button", { name: "Send message" }).click(), + * ]); + * await expect(page.getByText(adminMessage)).toBeVisible(); + * }); + * + * // Second user runs in their own context with a different storage state. + * const facAdminContext = await browser.newContext({ + * storageState: "tests/.auth/facilityAdmin.json", + * }); + * try { + * const facAdminPage = await facAdminContext.newPage(); + * + * await test.step("Facility admin sees the admin's message and replies", async () => { + * // Wait for a threads-list GET that actually contains our thread, so we don't + * // race the create propagation (React Query refetches on tab click). + * const threadsListed = facAdminPage.waitForResponse(async (resp) => { + * if ( + * !resp.url().includes("/thread/") || + * resp.request().method() !== "GET" || + * !resp.ok() + * ) { + * return false; + * } + * const body = await resp.json().catch(() => null); + * return body?.results?.some( + * (t: { title?: string }) => t.title === threadTitle, + * ); + * }); + * + * await facAdminPage.goto(encounterUrl); + * await facAdminPage.getByRole("tab", { name: "Notes" }).click(); + * await threadsListed; + * + * await facAdminPage.getByRole("button").filter({ hasText: threadTitle }).click(); + * await expect(facAdminPage.getByText(adminMessage)).toBeVisible(); + * + * await facAdminPage + * .getByPlaceholder("Type your message...") + * .fill(facAdminMessage); + * await facAdminPage.getByRole("button", { name: "Send message" }).click(); + * await expect(facAdminPage.getByText(facAdminMessage)).toBeVisible(); + * }); + * } finally { + * await facAdminContext.close(); + * } + * + * await test.step("Admin sees both messages after refresh", async () => { + * await page.goto(encounterUrl); + * await page.getByRole("tab", { name: "Notes" }).click(); + * await page.getByRole("button").filter({ hasText: threadTitle }).click(); + * await expect(page.getByText(adminMessage)).toBeVisible(); + * await expect(page.getByText(facAdminMessage)).toBeVisible(); + * }); + * }); + */ From b51a5ed80b278a1be175cd92cbe1a0248adeca3a Mon Sep 17 00:00:00 2001 From: nihal467 Date: Thu, 25 Jun 2026 17:07:55 +0530 Subject: [PATCH 3/4] docs(playwright): add bare-Label combobox and hasText descendant gotchas --- playwright/PLAYWRIGHT_GUIDE.md | 48 ++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/playwright/PLAYWRIGHT_GUIDE.md b/playwright/PLAYWRIGHT_GUIDE.md index 095f919..5e0b530 100644 --- a/playwright/PLAYWRIGHT_GUIDE.md +++ b/playwright/PLAYWRIGHT_GUIDE.md @@ -194,6 +194,54 @@ await page.getByRole("option", { name: "Active" }).first().click(); (e.g. `"Status"` also matches `"Operational Status"`). Use `.first()` on options when multiple matches are possible. +#### Gotcha: bare `