fix: stabilize medication Playwright gate
* fix: stabilize medication Playwright gate * fix: satisfy medication Playwright frontend gate
This commit is contained in:
+64
-13
@@ -1,10 +1,35 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { type APIResponse, type Cookie, expect, test as setup } from "@playwright/test";
|
||||
import { type APIResponse, expect, type Page, test as setup } from "@playwright/test";
|
||||
import { applyVideoSafetyMode, TEST_USER } from "./fixtures";
|
||||
|
||||
const authFile = path.join(import.meta.dirname, ".auth", "user.json");
|
||||
|
||||
type StoredAuthCookie = {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path: string;
|
||||
expires: number;
|
||||
httpOnly: boolean;
|
||||
secure: boolean;
|
||||
sameSite: "Strict" | "Lax" | "None";
|
||||
};
|
||||
|
||||
type BrowserCookie = {
|
||||
name: string;
|
||||
value: string;
|
||||
url: string;
|
||||
expires?: number;
|
||||
httpOnly: boolean;
|
||||
secure: boolean;
|
||||
sameSite: "Strict" | "Lax" | "None";
|
||||
};
|
||||
|
||||
type StoredAuthState = {
|
||||
cookies?: StoredAuthCookie[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a JWT token is still valid (not expired) without making a
|
||||
* network request. Returns `true` when the token has at least 2 minutes
|
||||
@@ -21,7 +46,7 @@ function isTokenValid(token: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function toBrowserCookie(setCookieHeader: string, baseURL: string): Cookie | null {
|
||||
function toBrowserCookie(setCookieHeader: string, baseURL: string): BrowserCookie | null {
|
||||
const segments = setCookieHeader
|
||||
.split(";")
|
||||
.map((segment) => segment.trim())
|
||||
@@ -36,7 +61,7 @@ function toBrowserCookie(setCookieHeader: string, baseURL: string): Cookie | nul
|
||||
return null;
|
||||
}
|
||||
|
||||
const cookie: Cookie = {
|
||||
const cookie: BrowserCookie = {
|
||||
name: nameValue.slice(0, separatorIndex),
|
||||
value: nameValue.slice(separatorIndex + 1),
|
||||
url: baseURL,
|
||||
@@ -90,16 +115,12 @@ function toBrowserCookie(setCookieHeader: string, baseURL: string): Cookie | nul
|
||||
return cookie;
|
||||
}
|
||||
|
||||
async function syncResponseCookiesToBrowserContext(
|
||||
page: Parameters<Parameters<typeof setup>[0]>[0]["page"],
|
||||
baseURL: string,
|
||||
response: APIResponse
|
||||
): Promise<void> {
|
||||
async function syncResponseCookiesToBrowserContext(page: Page, baseURL: string, response: APIResponse): Promise<void> {
|
||||
const cookies = response
|
||||
.headersArray()
|
||||
.filter((header) => header.name.toLowerCase() === "set-cookie")
|
||||
.map((header) => toBrowserCookie(header.value, baseURL))
|
||||
.filter((cookie): cookie is Cookie => cookie !== null);
|
||||
.filter((cookie): cookie is BrowserCookie => cookie !== null);
|
||||
|
||||
if (cookies.length > 0) {
|
||||
await page.context().addCookies(cookies);
|
||||
@@ -120,6 +141,7 @@ async function syncResponseCookiesToBrowserContext(
|
||||
setup("authenticate", async ({ page }) => {
|
||||
setup.setTimeout(120000);
|
||||
await applyVideoSafetyMode(page);
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
||||
|
||||
// Create .auth directory if it doesn't exist
|
||||
const authDir = path.dirname(authFile);
|
||||
@@ -130,11 +152,41 @@ setup("authenticate", async ({ page }) => {
|
||||
// ---- 1. Try to reuse an existing auth file (offline check only) ----
|
||||
if (fs.existsSync(authFile)) {
|
||||
try {
|
||||
const saved = JSON.parse(fs.readFileSync(authFile, "utf-8"));
|
||||
const saved = JSON.parse(fs.readFileSync(authFile, "utf-8")) as StoredAuthState;
|
||||
const accessCookie = saved.cookies?.find((c: { name: string }) => c.name === "access_token");
|
||||
const refreshCookie = saved.cookies?.find((c: { name: string }) => c.name === "refresh_token");
|
||||
|
||||
if (saved.cookies?.length) {
|
||||
await page.context().addCookies(saved.cookies);
|
||||
}
|
||||
|
||||
if (accessCookie?.value && isTokenValid(accessCookie.value)) {
|
||||
// Keep going and verify the session online. A JWT can be time-valid but
|
||||
// still rejected by backend token rotation/restart.
|
||||
const hasSavedSession = await page.request
|
||||
.get(`${baseURL}/api/auth/me`)
|
||||
.then((response) => response.ok())
|
||||
.catch(() => false);
|
||||
|
||||
if (hasSavedSession) {
|
||||
await page.context().storageState({ path: authFile });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (refreshCookie?.value) {
|
||||
const refreshResponse = await page.request.post(`${baseURL}/api/auth/refresh`).catch(() => null);
|
||||
if (refreshResponse?.ok()) {
|
||||
await syncResponseCookiesToBrowserContext(page, baseURL, refreshResponse);
|
||||
|
||||
const refreshedSession = await page.request
|
||||
.get(`${baseURL}/api/auth/me`)
|
||||
.then((response) => response.ok())
|
||||
.catch(() => false);
|
||||
|
||||
if (refreshedSession) {
|
||||
await page.context().storageState({ path: authFile });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Invalid file — fall through to regular login
|
||||
@@ -143,7 +195,6 @@ setup("authenticate", async ({ page }) => {
|
||||
|
||||
// ---- 2. Fast path: already authenticated session ----
|
||||
await page.goto("/");
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
||||
let authEnabled = true;
|
||||
let formLoginEnabled = true;
|
||||
let oidcEnabled = false;
|
||||
|
||||
@@ -303,7 +303,7 @@ export async function createMedicationViaAPI(data: {
|
||||
takenBy?: string[];
|
||||
notes?: string;
|
||||
expiryDate?: string;
|
||||
packageType?: "blister" | "bottle" | "tube" | "liquid_container";
|
||||
packageType?: "blister" | "bottle" | "tube" | "liquid_container" | "inhaler" | "injection";
|
||||
medicationForm?: "capsule" | "tablet" | "liquid" | "topical";
|
||||
packCount?: number;
|
||||
blistersPerPack?: number;
|
||||
@@ -323,7 +323,12 @@ export async function createMedicationViaAPI(data: {
|
||||
let token = await ensureAuthCookie();
|
||||
const apiBase = await getRuntimeApiBase();
|
||||
const packageType = data.packageType ?? "blister";
|
||||
const isAmountBased = packageType === "bottle" || packageType === "tube" || packageType === "liquid_container";
|
||||
const isAmountBased =
|
||||
packageType === "bottle" ||
|
||||
packageType === "tube" ||
|
||||
packageType === "liquid_container" ||
|
||||
packageType === "inhaler" ||
|
||||
packageType === "injection";
|
||||
let defaultMedicationForm: "capsule" | "tablet" | "liquid" | "topical" = "tablet";
|
||||
if (packageType === "tube") {
|
||||
defaultMedicationForm = "topical";
|
||||
|
||||
@@ -26,7 +26,7 @@ async function fillAndSaveMedication(
|
||||
opts: {
|
||||
name: string;
|
||||
genericName?: string;
|
||||
packageType?: "blister" | "bottle" | "tube" | "liquid_container";
|
||||
packageType?: "blister" | "bottle" | "tube" | "liquid_container" | "inhaler" | "injection";
|
||||
packs?: string;
|
||||
blistersPerPack?: string;
|
||||
pillsPerBlister?: string;
|
||||
@@ -50,12 +50,17 @@ async function fillAndSaveMedication(
|
||||
}
|
||||
|
||||
const packageTypeSelect = form.locator("select.package-type-select");
|
||||
if (opts.packageType === "bottle") {
|
||||
await packageTypeSelect.selectOption("bottle");
|
||||
if (opts.packageType === "bottle" || opts.packageType === "inhaler" || opts.packageType === "injection") {
|
||||
await packageTypeSelect.selectOption(opts.packageType ?? "bottle");
|
||||
await page.getByRole("tab", { name: /Package/i }).click();
|
||||
if (opts.totalCapacity)
|
||||
await form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i).fill(opts.totalCapacity);
|
||||
if (opts.currentPills) await form.getByLabel(/(Current Pills|form\.currentPills)/i).fill(opts.currentPills);
|
||||
await form
|
||||
.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\)|Total \(count\)|form\.totalCount)/i)
|
||||
.fill(opts.totalCapacity);
|
||||
if (opts.currentPills)
|
||||
await form
|
||||
.getByLabel(/(Current Pills|form\.currentPills|Current Stock|form\.currentStockCount)/i)
|
||||
.fill(opts.currentPills);
|
||||
} else if (opts.packageType === "tube") {
|
||||
await packageTypeSelect.selectOption("tube");
|
||||
await page.getByRole("tab", { name: /Package/i }).click();
|
||||
@@ -95,12 +100,12 @@ async function fillAndSaveMedication(
|
||||
await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
|
||||
}
|
||||
const row = form.locator(".blister-row").nth(i);
|
||||
await row
|
||||
.getByLabel(
|
||||
/(Usage \((pills|tablets|capsules|ml|applications)\)|form\.blisters\.(usage|usageTablets|usageCapsules|usageMl|usageApplication))/i
|
||||
)
|
||||
.fill(intakes[i].usage);
|
||||
await row.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill(intakes[i].every);
|
||||
const usageField = row.getByRole("textbox", {
|
||||
name: /(Usage|Tablets|Capsules|Applications|Puffs|Injections|Ml|form\.blisters\.usage|common\.(puffs|injections))/i,
|
||||
});
|
||||
const everyField = row.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i);
|
||||
await usageField.fill(intakes[i].usage);
|
||||
await everyField.fill(intakes[i].every);
|
||||
}
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
@@ -195,6 +200,38 @@ test.describe("Medication CRUD", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("should create an inhaler medication via the form", async ({ page }) => {
|
||||
await navigateTo(page, "/medications");
|
||||
|
||||
await fillAndSaveMedication(page, {
|
||||
name: "Test Rescue Inhaler",
|
||||
packageType: "inhaler",
|
||||
totalCapacity: "200",
|
||||
currentPills: "120",
|
||||
intakes: [{ usage: "2", every: "1" }],
|
||||
});
|
||||
|
||||
const medRow = page.locator(".med-row").filter({ hasText: "Test Rescue Inhaler" });
|
||||
await expect(medRow.locator(".med-details")).toContainText(/Inhaler|form\.packageTypeInhaler/i);
|
||||
await expect(medRow.locator(".med-total")).toContainText("120 / 200");
|
||||
});
|
||||
|
||||
test("should create an injection medication via the form", async ({ page }) => {
|
||||
await navigateTo(page, "/medications");
|
||||
|
||||
await fillAndSaveMedication(page, {
|
||||
name: "Test Weekly Injection",
|
||||
packageType: "injection",
|
||||
totalCapacity: "12",
|
||||
currentPills: "4",
|
||||
intakes: [{ usage: "1", every: "7" }],
|
||||
});
|
||||
|
||||
const medRow = page.locator(".med-row").filter({ hasText: "Test Weekly Injection" });
|
||||
await expect(medRow.locator(".med-details")).toContainText(/Injection|form\.packageTypeInjection/i);
|
||||
await expect(medRow.locator(".med-total")).toContainText("4 / 12");
|
||||
});
|
||||
|
||||
test("should create medication with multiple intake schedules", async ({ page }) => {
|
||||
await navigateTo(page, "/medications");
|
||||
|
||||
|
||||
@@ -33,6 +33,28 @@ async function clickEditMed(page: Page, medName: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function openMedicationDetailFromDashboard(page: Page, medName: string) {
|
||||
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||
const medRow = overviewTable.locator(".table-row").filter({ hasText: medName });
|
||||
await expect(medRow).toBeVisible({ timeout: 10000 });
|
||||
await medRow.click();
|
||||
const modal = page.locator(".modal-content.med-detail-modal");
|
||||
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||
await expect(modal.getByText(medName)).toBeVisible({ timeout: 5000 });
|
||||
return modal;
|
||||
} catch {
|
||||
if (attempt === 2) throw new Error(`Failed to open dashboard medication detail for ${medName}`);
|
||||
await page.reload();
|
||||
await page.waitForLoadState("networkidle");
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Failed to open dashboard medication detail for ${medName}`);
|
||||
}
|
||||
|
||||
/** Helper: save edit and verify success */
|
||||
async function saveEditAndVerify(page: Page, medName: string): Promise<void> {
|
||||
const form = page.locator("form.form-grid:visible").first();
|
||||
@@ -310,24 +332,107 @@ test.describe("Medication Editing", () => {
|
||||
|
||||
// Find the remind checkbox in the intake row
|
||||
const intakeRow = page.locator(".blister-row").first();
|
||||
const remindCheckbox = intakeRow.locator('input[type="checkbox"]');
|
||||
const remindToggle = intakeRow.locator(".toggle-switch");
|
||||
const remindCheckbox = intakeRow.locator('.toggle-switch input[type="checkbox"]');
|
||||
|
||||
if (await remindCheckbox.isVisible().catch(() => false)) {
|
||||
// Should be unchecked initially
|
||||
await expect(remindCheckbox).not.toBeChecked();
|
||||
await remindToggle.click();
|
||||
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('.toggle-switch input[type="checkbox"]');
|
||||
await expect(savedCheckbox).toBeChecked();
|
||||
});
|
||||
|
||||
for (const scenario of [
|
||||
{
|
||||
name: "Inhaler Reminder Refill Med",
|
||||
packageType: "inhaler" as const,
|
||||
totalCapacity: 200,
|
||||
currentStock: 120,
|
||||
refillAmount: 30,
|
||||
expectedStock: 150,
|
||||
unitLabel: /puffs?|common\.puffs?/i,
|
||||
},
|
||||
{
|
||||
name: "Injection Reminder Refill Med",
|
||||
packageType: "injection" as const,
|
||||
totalCapacity: 12,
|
||||
currentStock: 4,
|
||||
refillAmount: 3,
|
||||
expectedStock: 7,
|
||||
unitLabel: /injections?|common\.injections?/i,
|
||||
},
|
||||
]) {
|
||||
test(`should persist reminders and refill ${scenario.packageType} stock without drift`, async ({ page }) => {
|
||||
createdMeds.push(
|
||||
await createMedicationViaAPI({
|
||||
name: scenario.name,
|
||||
packageType: scenario.packageType,
|
||||
totalPills: scenario.totalCapacity,
|
||||
looseTablets: scenario.currentStock,
|
||||
intakes: [
|
||||
{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: new Date().toISOString().slice(0, 16),
|
||||
intakeRemindersEnabled: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
await navigateTo(page, "/medications");
|
||||
await clickEditMed(page, scenario.name);
|
||||
await page.getByRole("tab", { name: /Schedule/i }).click();
|
||||
|
||||
const intakeRow = page.locator(".blister-row").first();
|
||||
const remindToggle = intakeRow.locator(".toggle-switch");
|
||||
const remindCheckbox = intakeRow.locator('.toggle-switch input[type="checkbox"]');
|
||||
await expect(remindCheckbox).not.toBeChecked();
|
||||
|
||||
// Enable it
|
||||
await remindCheckbox.check();
|
||||
await remindToggle.click();
|
||||
await expect(remindCheckbox).toBeChecked();
|
||||
|
||||
await saveEditAndVerify(page, "Reminder Toggle Med");
|
||||
await saveEditAndVerify(page, scenario.name);
|
||||
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
await clickEditMed(page, scenario.name);
|
||||
await page.getByRole("tab", { name: /Schedule/i }).click();
|
||||
await expect(page.locator(".blister-row").first().locator('.toggle-switch input[type="checkbox"]')).toBeChecked();
|
||||
|
||||
await navigateTo(page, "/dashboard");
|
||||
const modal = await openMedicationDetailFromDashboard(page, scenario.name);
|
||||
|
||||
await modal.getByRole("button", { name: /Refill|refill\.button/i }).click();
|
||||
const refillModal = page.locator(".modal-content.refill-modal");
|
||||
await expect(refillModal).toBeVisible({ timeout: 5000 });
|
||||
const refillInput = refillModal.locator('input[type="number"]').first();
|
||||
await refillInput.fill(String(scenario.refillAmount));
|
||||
await expect(refillModal.locator(".refill-preview")).toContainText(`+${scenario.refillAmount}`);
|
||||
await expect(refillModal.locator(".refill-preview")).toContainText(scenario.unitLabel);
|
||||
|
||||
await refillModal.locator(".modal-footer .success").click();
|
||||
await expect(refillModal).not.toBeVisible({ timeout: 10000 });
|
||||
|
||||
const refillHistoryHeader = modal.locator(".med-detail-section h3").filter({
|
||||
hasText: /Refill History|refill\.history/i,
|
||||
});
|
||||
await expect(refillHistoryHeader).toBeVisible({ timeout: 10000 });
|
||||
await refillHistoryHeader.click();
|
||||
const refillAmount = modal.locator(".refill-history-item .refill-amount").first();
|
||||
await expect(refillAmount).toContainText(`+${scenario.refillAmount}`);
|
||||
await expect(refillAmount).toContainText(scenario.unitLabel);
|
||||
|
||||
await page.locator("button.modal-close").click();
|
||||
await expect(modal).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
await navigateTo(page, "/medications");
|
||||
const medRow = page.locator(".med-row").filter({ hasText: scenario.name });
|
||||
await expect(medRow.locator(".med-total")).toContainText(`${scenario.expectedStock} / ${scenario.totalCapacity}`);
|
||||
});
|
||||
}
|
||||
|
||||
test("should change package type across all supported profiles", async ({ page }) => {
|
||||
createdMeds.push(
|
||||
@@ -369,12 +474,30 @@ test.describe("Medication Editing", () => {
|
||||
await packageSelect.selectOption("liquid_container");
|
||||
await page.getByRole("tab", { name: /Package/i }).click();
|
||||
await expect(form.getByLabel(/(Package amount|form\.packageAmount)/i)).toBeVisible();
|
||||
await page.getByRole("tab", { name: /General/i }).click();
|
||||
|
||||
// Switch to inhaler
|
||||
await packageSelect.selectOption("inhaler");
|
||||
await page.getByRole("tab", { name: /Package/i }).click();
|
||||
await expect(
|
||||
form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(count\)|form\.totalCount)/i)
|
||||
).toBeVisible();
|
||||
await expect(form.getByLabel(/(Current Stock|form\.currentStockCount)/i)).toBeVisible();
|
||||
await page.getByRole("tab", { name: /General/i }).click();
|
||||
|
||||
// Switch to injection and persist this final state
|
||||
await packageSelect.selectOption("injection");
|
||||
await page.getByRole("tab", { name: /Package/i }).click();
|
||||
await expect(
|
||||
form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(count\)|form\.totalCount)/i)
|
||||
).toBeVisible();
|
||||
await expect(form.getByLabel(/(Current Stock|form\.currentStockCount)/i)).toBeVisible();
|
||||
|
||||
await saveEditAndVerify(page, "PackType Change Med");
|
||||
|
||||
// Verify final package type persisted
|
||||
await clickEditMed(page, "PackType Change Med");
|
||||
await expect(page.locator("select.package-type-select")).toHaveValue("liquid_container");
|
||||
await expect(page.locator("select.package-type-select")).toHaveValue("injection");
|
||||
});
|
||||
|
||||
test("should edit multiple fields at once (name, notes, generic, taken-by)", async ({ page }) => {
|
||||
|
||||
Reference in New Issue
Block a user