feat: expand Playwright E2E coverage (#155)

* feat: comprehensive Playwright E2E test rewrite

Rewrite all E2E tests with correct CSS selectors, add new spec files,
and implement robust auth handling to work within backend rate limits.

Changes:
- Rewrite fixtures/index.ts with JWT-based /auth/me mock to avoid
  10 req/min rate limit on /auth/me during test runs
- Rewrite auth.setup.ts with offline JWT validity check to reuse
  existing auth state across runs (saves login rate-limit budget)
- Rewrite auth.spec.ts (6 tests) - login page, fields, submit,
  redirect guard, invalid credentials, login/register toggle
- Rewrite dashboard.spec.ts (8 tests) - header, nav tabs,
  navigation, overview/schedules sections, days selector, redirect
- Rewrite medications.spec.ts (8 tests) - form fields, stock
  inventory, package type toggle, intake schedule, save/cancel,
  unsaved changes guard
- Rewrite settings.spec.ts (12 tests) - language, notification
  matrix, thresholds, calculation mode, toggle switch, export/import,
  user menu navigation
- Create planner.spec.ts (9 tests) - form, date inputs, calculate,
  reset, checkbox, submit, tab state, eyebrow heading
- Create schedule.spec.ts (12 tests) - timeline, days selector,
  past/future toggles, day blocks, today highlight, collapse/expand,
  overview table, share button
- Update playwright.config.ts: remove mobile projects, enable
  webServer section for CI
- Add .github/workflows/e2e.yml CI workflow for Playwright tests

Total: 57 E2E tests across 6 spec files, all passing consistently
across 5+ consecutive runs without backend restart.

Closes #154

* feat: add comprehensive E2E data tests with medication CRUD, dashboard, planner, schedule

Add 48 new Playwright E2E tests covering real medication data scenarios:
- medication-crud: 14 tests for create/edit/delete/list via UI form
- dashboard-data: 13 tests for overview table, timeline, dose tracking
- planner-data: 9 tests for demand calculator with results/status chips
- schedule-data: 11 tests for timeline, collapse/expand, dose mark/undo

Infrastructure improvements:
- Add API helpers (createMedicationViaAPI, deleteMedicationViaAPI,
  deleteAllMedicationsViaAPI) with retry logic for rate-limit resilience
- Configure chromium-data project for serial execution with retry:1
- Add /auth/me mock to avoid rate-limit exhaustion on auth endpoint
- Increase navigateTo reliability with networkidle waits
- Increase auth token validity threshold from 2 to 10 minutes
- Make backend rate limit configurable via RATE_LIMIT_MAX env var
- Set RATE_LIMIT_MAX=300 in dev docker-compose for E2E test support

Total suite: 57 empty-state + 48 data tests = 105 tests (chromium)

* test: add E2E tests for medication editing, stock status, and share schedule

- medication-edit.spec.ts: 10 tests covering generic name, notes,
  taken-by add/remove, expiry date, refill, intake schedule editing,
  adding intake rows, reminder toggle, and package type changes
- stock-status.spec.ts: 12 tests verifying dashboard shows correct
  status chips (High/Normal/Warning/Danger) for different stock levels,
  overview table, reorder card, detail modal, and planner integration
- share-schedule.spec.ts: 10 tests for taken-by badges, share button,
  share dialog, link generation, shared schedule page navigation,
  dose tracking on shared page, and notes display
- fixtures/index.ts: add createShareTokenViaAPI, updateSettingsViaAPI
  helpers; expand createMedicationViaAPI with takenBy, notes, expiryDate
- playwright.config.ts: update testMatch/testIgnore for new test files
- docker-compose.dev.yml: increase RATE_LIMIT_MAX to 1000 for E2E tests

* docs: refine release-manager instructions for CLI safety and commit-linked release notes

* fix: resolve PR155 CI failures for frontend lint and e2e proxy

* fix: stabilize auth-related e2e checks in CI
This commit is contained in:
Daniel Volz
2026-02-12 20:06:11 +01:00
committed by GitHub
parent 0f6a580ceb
commit 98939877db
24 changed files with 3385 additions and 650 deletions
+412
View File
@@ -0,0 +1,412 @@
import type { Page } from "@playwright/test";
import {
authFile,
createMedicationViaAPI,
deleteAllMedicationsViaAPI,
expect,
navigateTo,
type TestMedication,
test,
} from "./fixtures";
/**
* Medication Edit E2E Tests
*
* Tests editing medications: changing fields, adding notes, taken-by persons,
* generic name, refill stock, intake reminders, and intake schedule changes.
* Each test creates a medication via API, edits it via the UI, and verifies the change.
*/
/** Helper: click Edit button on a medication row */
async function clickEditMed(page: Page, medName: string): Promise<void> {
const medRow = page.locator(".med-row").filter({ hasText: medName });
for (let attempt = 0; attempt < 3; attempt++) {
if (await medRow.isVisible().catch(() => false)) break;
await page.reload();
await page.waitForLoadState("networkidle");
await page.waitForTimeout(1000);
}
await expect(medRow).toBeVisible({ timeout: 10000 });
await medRow.locator("button.info").click();
await expect(page.locator("h2").filter({ hasText: /Edit medication/i })).toBeVisible({ timeout: 5000 });
}
/** Helper: save edit and verify success */
async function saveEditAndVerify(page: Page, medName: string): Promise<void> {
// Wait for any pending network before clicking save
await page.waitForLoadState("networkidle");
// Click save
const saveBtn = page.locator("form.form-grid button[type='submit']");
await saveBtn.click();
// Wait for save request + re-fetch to complete
await page.waitForLoadState("networkidle");
// Reload page to get fresh data from the backend
// This ensures the meds array passed to startEdit has the saved changes
await page.reload();
await page.waitForLoadState("networkidle");
// Verify the med row is visible in the list
const medRow = page.locator(".med-row").filter({ hasText: medName });
await expect(medRow).toBeVisible({ timeout: 10000 });
}
test.describe("Medication Editing", () => {
test.use({ storageState: authFile });
test.describe.configure({ timeout: 60000 });
const createdMeds: TestMedication[] = [];
test.beforeAll(async () => {
await deleteAllMedicationsViaAPI();
});
test.afterAll(async () => {
await deleteAllMedicationsViaAPI();
});
test("should edit generic name on an existing medication", async ({ page }) => {
createdMeds.push(await createMedicationViaAPI({ name: "Edit GenName Med" }));
await navigateTo(page, "/medications");
await clickEditMed(page, "Edit GenName Med");
// Generic name should be empty initially
const genericField = page.getByLabel(/Generic Name/i);
await expect(genericField).toHaveValue("");
// Add a generic name
await genericField.fill("Acetylsalicylic acid");
await expect(genericField).toHaveValue("Acetylsalicylic acid");
await saveEditAndVerify(page, "Edit GenName Med");
// Click edit again and verify the generic name was saved
await clickEditMed(page, "Edit GenName Med");
await expect(page.getByLabel(/Generic Name/i)).toHaveValue("Acetylsalicylic acid");
});
test("should add notes to an existing medication", async ({ page }) => {
createdMeds.push(await createMedicationViaAPI({ name: "Edit Notes Med" }));
await navigateTo(page, "/medications");
await clickEditMed(page, "Edit Notes Med");
// Notes should be empty initially
const notesField = page.getByLabel(/Notes/i);
await expect(notesField).toHaveValue("");
// Add notes text
await notesField.fill("Take with food after breakfast. Do not exceed 3 per day. Store below 25°C.");
await expect(notesField).toContainText("Take with food after breakfast");
await saveEditAndVerify(page, "Edit Notes Med");
// Verify notes were saved by clicking edit again
await clickEditMed(page, "Edit Notes Med");
await expect(page.getByLabel(/Notes/i)).toContainText("Take with food after breakfast");
});
test("should add taken-by person to a medication", async ({ page }) => {
createdMeds.push(await createMedicationViaAPI({ name: "TakenBy Med" }));
await navigateTo(page, "/medications");
await clickEditMed(page, "TakenBy Med");
// Find the taken-by input field inside the tag-input-container
const takenByContainer = page.locator(".tag-input-container");
await expect(takenByContainer).toBeVisible();
const takenByInput = takenByContainer.locator("input");
// Add a person name
await takenByInput.fill("Alice");
await takenByInput.press("Enter");
// Tag should appear
await expect(takenByContainer.locator(".tag").filter({ hasText: "Alice" })).toBeVisible();
// Add another person
await takenByInput.fill("Bob");
await takenByInput.press("Enter");
await expect(takenByContainer.locator(".tag").filter({ hasText: "Bob" })).toBeVisible();
await saveEditAndVerify(page, "TakenBy Med");
// Verify tags are persisted
await clickEditMed(page, "TakenBy Med");
await expect(page.locator(".tag-input-container .tag").filter({ hasText: "Alice" })).toBeVisible();
await expect(page.locator(".tag-input-container .tag").filter({ hasText: "Bob" })).toBeVisible();
});
test("should remove a taken-by person from a medication", async ({ page }) => {
createdMeds.push(
await createMedicationViaAPI({
name: "Remove TakenBy Med",
takenBy: ["Alice", "Bob"],
})
);
await navigateTo(page, "/medications");
await clickEditMed(page, "Remove TakenBy Med");
// Both persons should appear as tags
const container = page.locator(".tag-input-container");
await expect(container.locator(".tag")).toHaveCount(2, { timeout: 5000 });
// Use Backspace in the empty input to remove the last tag (Bob)
// The app handles this: if input empty + backspace → remove last takenBy person
const takenByInput = container.locator("input");
await takenByInput.click();
await takenByInput.press("Backspace");
// After backspace, Bob (the last tag) should be removed, leaving Alice
await expect(container.locator(".tag")).toHaveCount(1, { timeout: 5000 });
await expect(container.locator(".tag").filter({ hasText: "Alice" })).toBeVisible();
await saveEditAndVerify(page, "Remove TakenBy Med");
// Verify only Alice remains after save
await clickEditMed(page, "Remove TakenBy Med");
await expect(container.locator(".tag")).toHaveCount(1, { timeout: 5000 });
await expect(container.locator(".tag").filter({ hasText: "Alice" })).toBeVisible();
});
test("should add an expiry date to a medication", async ({ page }) => {
createdMeds.push(await createMedicationViaAPI({ name: "Expiry Date Med" }));
await navigateTo(page, "/medications");
await clickEditMed(page, "Expiry Date Med");
// Set expiry date to 6 months from now
const expiryDate = new Date(Date.now() + 180 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
const expiryField = page.getByLabel(/Expiry Date/i);
await expiryField.fill(expiryDate);
await expect(expiryField).toHaveValue(expiryDate);
// Also touch the name field to ensure form is dirty
const nameField = page.getByLabel(/Commercial Name/i);
const currentName = await nameField.inputValue();
await nameField.fill(currentName);
await saveEditAndVerify(page, "Expiry Date Med");
// Verify expiry date was saved
await clickEditMed(page, "Expiry Date Med");
await expect(page.getByLabel(/Expiry Date/i)).toHaveValue(expiryDate);
});
test("should use refill feature to add stock in edit mode", async ({ page }) => {
createdMeds.push(
await createMedicationViaAPI({
name: "Refill Test Med",
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
})
);
await navigateTo(page, "/medications");
await clickEditMed(page, "Refill Test Med");
// Refill section should be visible in edit mode
const refillSection = page.locator(".refill-section");
await expect(refillSection).toBeVisible();
// Set refill values: 2 packs + 5 loose pills
await refillSection.getByLabel(/Packs/i).fill("2");
await refillSection.getByLabel(/Loose pills/i).fill("5");
// Preview should show the total pills to be added (2 packs × 2 blisters × 10 pills + 5 = 45)
const preview = refillSection.locator(".refill-preview");
await expect(preview).toBeVisible();
expect(await preview.textContent()).toContain("45");
// Click the refill button
await refillSection.locator("button.success").click();
// Wait for the refill to be processed
await page.waitForLoadState("networkidle");
});
test("should edit intake schedule usage and interval", async ({ page }) => {
createdMeds.push(
await createMedicationViaAPI({
name: "Edit Intake Med",
intakes: [
{
usage: 1,
every: 1,
start: new Date().toISOString().slice(0, 16),
intakeRemindersEnabled: false,
},
],
})
);
await navigateTo(page, "/medications");
await clickEditMed(page, "Edit Intake Med");
// Change intake from 1 pill daily to 2 pills every 7 days
const intakeRow = page.locator(".blister-row").first();
const usageField = intakeRow.getByLabel(/Usage \(pills\)/i);
const everyField = intakeRow.getByLabel(/Every \(days\)/i);
await usageField.fill("2");
await everyField.fill("7");
await expect(usageField).toHaveValue("2");
await expect(everyField).toHaveValue("7");
await saveEditAndVerify(page, "Edit Intake Med");
// Verify the changes persisted
await clickEditMed(page, "Edit Intake Med");
const savedRow = page.locator(".blister-row").first();
await expect(savedRow.getByLabel(/Usage \(pills\)/i)).toHaveValue("2");
await expect(savedRow.getByLabel(/Every \(days\)/i)).toHaveValue("7");
});
test("should add a second intake schedule row", async ({ page }) => {
createdMeds.push(
await createMedicationViaAPI({
name: "Add Intake Med",
intakes: [
{
usage: 1,
every: 1,
start: new Date().toISOString().slice(0, 16),
intakeRemindersEnabled: false,
},
],
})
);
await navigateTo(page, "/medications");
await clickEditMed(page, "Add Intake Med");
// Should have 1 intake row initially
await expect(page.locator(".blister-row")).toHaveCount(1);
// Add a second intake
await page.getByRole("button", { name: /Intake/i }).click();
await expect(page.locator(".blister-row")).toHaveCount(2);
// Fill the new intake row
const secondRow = page.locator(".blister-row").nth(1);
await secondRow.getByLabel(/Usage \(pills\)/i).fill("0.5");
await secondRow.getByLabel(/Every \(days\)/i).fill("7");
await saveEditAndVerify(page, "Add Intake Med");
// Verify 2 intakes persisted
await clickEditMed(page, "Add Intake Med");
await expect(page.locator(".blister-row")).toHaveCount(2, { timeout: 10000 });
});
test("should toggle intake reminder on a medication", async ({ page }) => {
createdMeds.push(
await createMedicationViaAPI({
name: "Reminder Toggle Med",
intakes: [
{
usage: 1,
every: 1,
start: new Date().toISOString().slice(0, 16),
intakeRemindersEnabled: false,
},
],
})
);
await navigateTo(page, "/medications");
await clickEditMed(page, "Reminder Toggle Med");
// Find the remind checkbox in the intake row
const intakeRow = page.locator(".blister-row").first();
const remindCheckbox = intakeRow.locator('input[type="checkbox"]');
if (await remindCheckbox.isVisible().catch(() => false)) {
// Should be unchecked initially
await expect(remindCheckbox).not.toBeChecked();
// Enable it
await remindCheckbox.check();
await expect(remindCheckbox).toBeChecked();
await saveEditAndVerify(page, "Reminder Toggle Med");
// Verify reminder was saved
await clickEditMed(page, "Reminder Toggle Med");
const savedCheckbox = page.locator(".blister-row").first().locator('input[type="checkbox"]');
await expect(savedCheckbox).toBeChecked();
}
});
test("should change package type between blister and bottle", async ({ page }) => {
createdMeds.push(
await createMedicationViaAPI({
name: "PackType Change Med",
packageType: "blister",
packCount: 2,
blistersPerPack: 3,
pillsPerBlister: 10,
})
);
await navigateTo(page, "/medications");
await clickEditMed(page, "PackType Change Med");
// Should be blister type initially
const packageSelect = page.locator("select.package-type-select");
await expect(packageSelect).toHaveValue("blister");
// Blister-specific fields should be visible
await expect(page.getByLabel(/Blisters per pack/i)).toBeVisible();
// Switch to bottle
await packageSelect.selectOption("bottle");
await expect(page.getByLabel(/Total Capacity/i)).toBeVisible();
// Fill bottle-specific fields
await page.getByLabel(/Total Capacity/i).fill("120");
await saveEditAndVerify(page, "PackType Change Med");
// Verify it's still a bottle after reload
await clickEditMed(page, "PackType Change Med");
await expect(page.locator("select.package-type-select")).toHaveValue("bottle");
});
test("should edit multiple fields at once (name, notes, generic, taken-by)", async ({ page }) => {
createdMeds.push(await createMedicationViaAPI({ name: "Multi Edit Med" }));
await navigateTo(page, "/medications");
await clickEditMed(page, "Multi Edit Med");
// Change the name
await page.getByLabel(/Commercial Name/i).fill("Fully Edited Med");
// Add generic name
await page.getByLabel(/Generic Name/i).fill("Ibuprofen Lysinate");
// Add notes
await page.getByLabel(/Notes/i).fill("Morning dose only. Take with plenty of water.");
// Add a taken-by person
const takenByInput = page.locator(".tag-input-container input");
await takenByInput.fill("Charlie");
await takenByInput.press("Enter");
await expect(page.locator(".tag-input-container .tag").filter({ hasText: "Charlie" })).toBeVisible();
await saveEditAndVerify(page, "Fully Edited Med");
// Verify all changes persisted
await clickEditMed(page, "Fully Edited Med");
await expect(page.getByLabel(/Commercial Name/i)).toHaveValue("Fully Edited Med");
await expect(page.getByLabel(/Generic Name/i)).toHaveValue("Ibuprofen Lysinate");
await expect(page.getByLabel(/Notes/i)).toContainText("Morning dose only");
await expect(page.locator(".tag-input-container .tag").filter({ hasText: "Charlie" })).toBeVisible();
});
});