fix: stabilize e2e suite and align dev runtime config (#408)
* fix: stabilize e2e suite and align dev runtime config * fix: harden forbidden-settings e2e assertion * fix: make forbidden settings e2e assertion robust
This commit is contained in:
@@ -12,6 +12,7 @@ PGID=1000
|
||||
PORT=3000
|
||||
CORS_ORIGINS=http://localhost:4174
|
||||
LOG_LEVEL=warn
|
||||
|
||||
# Levels: debug, info, warn, error, silent
|
||||
# Controls: backend Fastify logging, frontend nginx access logs (Docker),
|
||||
# and frontend browser console (via build-time injection)
|
||||
@@ -28,6 +29,10 @@ LOG_LEVEL=warn
|
||||
# Increase for development/testing environments
|
||||
# RATE_LIMIT_MAX=100
|
||||
|
||||
# API documentation UI + OpenAPI JSON
|
||||
# Default behavior: enabled outside production, disabled in production
|
||||
# OPENAPI_DOCS_ENABLED=true
|
||||
|
||||
# Timezone for scheduled reminders (e.g., Europe/Berlin, America/New_York)
|
||||
TZ=Europe/Berlin
|
||||
|
||||
|
||||
Vendored
+67
-48
@@ -1,49 +1,68 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "E2E stable",
|
||||
"type": "shell",
|
||||
"command": "npm",
|
||||
"args": ["run", "test:e2e"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/frontend"
|
||||
},
|
||||
"group": "test",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "E2E stable + merged video",
|
||||
"type": "shell",
|
||||
"command": "npm",
|
||||
"args": ["run", "test:e2e:with-video"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/frontend"
|
||||
},
|
||||
"group": "test",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "E2E all browsers",
|
||||
"type": "shell",
|
||||
"command": "npm",
|
||||
"args": ["run", "test:e2e:all"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/frontend"
|
||||
},
|
||||
"group": "test",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "E2E all browsers + merged video",
|
||||
"type": "shell",
|
||||
"command": "npm",
|
||||
"args": ["run", "test:e2e:all:with-video"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/frontend"
|
||||
},
|
||||
"group": "test",
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "E2E stable",
|
||||
"type": "shell",
|
||||
"command": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"test:e2e"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/frontend"
|
||||
},
|
||||
"group": "test",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "E2E stable + merged video",
|
||||
"type": "shell",
|
||||
"command": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"test:e2e:with-video"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/frontend"
|
||||
},
|
||||
"group": "test",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "E2E all browsers",
|
||||
"type": "shell",
|
||||
"command": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"test:e2e:all"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/frontend"
|
||||
},
|
||||
"group": "test",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "E2E all browsers + merged video",
|
||||
"type": "shell",
|
||||
"command": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"test:e2e:all:with-video"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/frontend"
|
||||
},
|
||||
"group": "test",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "E2E stable non-interactive",
|
||||
"type": "shell",
|
||||
"command": "cd frontend && PLAYWRIGHT_HTML_OPEN=never PLAYWRIGHT_WORKERS=1 npm run test:e2e -- --workers=1",
|
||||
"isBackground": false,
|
||||
"group": "test"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -177,7 +177,7 @@ The easiest way to deploy MedAssist-ng is with Docker Compose:
|
||||
git clone https://github.com/DanielVolz/medassist-ng.git
|
||||
cd medassist-ng
|
||||
cp .env.example .env
|
||||
docker compose up -d
|
||||
docker compose -p medassist-ng up -d
|
||||
```
|
||||
|
||||
Open `http://localhost:4174` and start tracking your medications.
|
||||
@@ -195,6 +195,7 @@ All configuration is done via environment variables in `.env`. Copy `.env.exampl
|
||||
| `PORT` | `3000` | Backend API port |
|
||||
| `CORS_ORIGINS` | `http://localhost:4174` | Allowed origins for CORS |
|
||||
| `LOG_LEVEL` | `info` | Log verbosity (`debug`, `info`, `warn`, `error`, `silent`). At `info` (default), high-frequency polling endpoints are suppressed. Set `debug` to see all requests. |
|
||||
| `OPENAPI_DOCS_ENABLED` | `auto` | Enables API docs in non-production by default. Set explicitly to `true`/`false` to override. |
|
||||
| `TZ` | `Europe/Berlin` | Timezone for scheduled reminders |
|
||||
|
||||
### Authentication
|
||||
@@ -211,6 +212,42 @@ All configuration is done via environment variables in `.env`. Copy `.env.exampl
|
||||
|
||||
Generate secrets with: `openssl rand -hex 32`
|
||||
|
||||
### API Keys (Programmatic API Access)
|
||||
|
||||
When `AUTH_ENABLED=true`, you can create personal API keys and call protected endpoints with:
|
||||
|
||||
```bash
|
||||
Authorization: Bearer ma_...
|
||||
```
|
||||
|
||||
Available scopes:
|
||||
|
||||
- `read`: read-only access (`GET`, `HEAD`, `OPTIONS`)
|
||||
- `write`: read + write access
|
||||
|
||||
Essential notes:
|
||||
|
||||
- Create keys in the app when authentication is enabled.
|
||||
- The token is shown only once after creation.
|
||||
- Creating a new key automatically deactivates previously active keys for the same user.
|
||||
- API keys are stored hashed in the database.
|
||||
|
||||
Example usage:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/settings \
|
||||
-H "Authorization: Bearer ma_..."
|
||||
```
|
||||
|
||||
API reference:
|
||||
|
||||
- Interactive docs: `/docs`
|
||||
- OpenAPI JSON: `/docs/json`
|
||||
- Key management endpoints for authenticated users:
|
||||
- `GET /auth/api-keys`
|
||||
- `POST /auth/api-keys`
|
||||
- `DELETE /auth/api-keys/:id`
|
||||
|
||||
### OIDC / SSO
|
||||
|
||||
| Variable | Default | Description |
|
||||
@@ -309,30 +346,22 @@ For all services and options, see the [Shoutrrr documentation](https://containrr
|
||||
# Development
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up
|
||||
docker compose -p medassist-dev -f docker-compose.dev.yml up
|
||||
```
|
||||
|
||||
- Frontend: `http://localhost:5173` (hot reload)
|
||||
- Backend: `http://localhost:3000`
|
||||
- API docs UI: `http://localhost:3000/docs` (when docs are enabled)
|
||||
- OpenAPI JSON: `http://localhost:3000/docs/json` (when docs are enabled)
|
||||
|
||||
Playwright E2E recommendations:
|
||||
Useful local commands:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run test:e2e:local # local run with PLAYWRIGHT_WORKERS=4
|
||||
npm run test:e2e:all:local # local all-browser run with PLAYWRIGHT_WORKERS=4
|
||||
npm run lint
|
||||
cd backend && npm run test:run
|
||||
cd frontend && npm run test:run
|
||||
```
|
||||
|
||||
- CI stays at `PLAYWRIGHT_WORKERS=1` for stability.
|
||||
- Data-heavy specs remain sequential via the `chromium-data` project config.
|
||||
|
||||
# Dependency Updates
|
||||
|
||||
- Dependabot checks dependencies weekly for `frontend`, `backend`, repository root tooling, and GitHub Actions.
|
||||
- Minor and patch updates are grouped to reduce PR noise.
|
||||
- Dependabot minor/patch PRs are configured for auto-merge after required CI checks pass.
|
||||
- Major updates still require manual review before merge.
|
||||
|
||||
# Acknowledgements
|
||||
|
||||
This project was inspired by [MedAssist](https://github.com/njic/medassist) by njic.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
name: medassist-dev
|
||||
|
||||
services:
|
||||
backend-dev:
|
||||
image: node:22-slim
|
||||
@@ -10,6 +12,7 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- DATA_DIR=/app/data
|
||||
- RATE_LIMIT_MAX=1000
|
||||
ports:
|
||||
@@ -33,6 +36,7 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- BACKEND_URL=http://backend-dev:3000
|
||||
ports:
|
||||
- "5173:5173"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
name: medassist-ng
|
||||
|
||||
services:
|
||||
backend:
|
||||
image: ghcr.io/danielvolz/medassist-ng-backend:latest
|
||||
|
||||
@@ -117,6 +117,9 @@ test.describe("Dashboard with medications", () => {
|
||||
|
||||
test("should show day summary with dose progress", async ({ page }) => {
|
||||
await navigateTo(page, "/dashboard");
|
||||
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||
await expect(overviewTable.getByText(MED_1)).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const todayBlock = page.locator(".day-block.today");
|
||||
await expect(todayBlock).toBeVisible({ timeout: 10000 });
|
||||
|
||||
@@ -427,6 +427,15 @@ export async function createShareTokenViaAPI(takenBy: string, scheduleDays = 30)
|
||||
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
||||
continue;
|
||||
}
|
||||
if (res.status === 400) {
|
||||
const text = await res.text();
|
||||
if (text.includes('"code":"NO_MEDICATIONS"') && attempt < 4) {
|
||||
// Freshly seeded E2E medication data can lag briefly behind the share lookup.
|
||||
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Failed to create share token: ${res.status} ${text}`);
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Failed to create share token: ${res.status} ${text}`);
|
||||
|
||||
@@ -195,8 +195,13 @@ test.describe("Schedule with medications", () => {
|
||||
const takeBtn = todayBlock.locator("button.dose-btn.take:not([disabled])").first();
|
||||
test.skip(!(await takeBtn.isVisible().catch(() => false)), "No actionable take-dose button is visible for today");
|
||||
|
||||
await takeBtn.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) => response.url().includes("/api/doses/taken") && response.request().method() === "POST",
|
||||
{ timeout: 10000 }
|
||||
),
|
||||
takeBtn.click(),
|
||||
]);
|
||||
await expect(todayBlock.locator("button.dose-btn.undo").first()).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
|
||||
@@ -157,7 +157,9 @@ test.describe("Schedule Timeline", () => {
|
||||
|
||||
test("should display share button in schedules section", async ({ page }) => {
|
||||
await navigateTo(page, "/dashboard");
|
||||
await expect(page.locator(".taken-by-badge").first()).toBeVisible();
|
||||
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||
await expect(overviewTable.locator(".table-row").first()).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const shareBtn = page.locator("button.share-btn");
|
||||
await expect(shareBtn).toBeVisible();
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { authFile, navigateTo, test } from "./fixtures";
|
||||
|
||||
const emailHeadingPattern = /Email|E-Mail/i;
|
||||
const smtpUnavailablePattern = /stay unavailable until SMTP is configured|bleiben deaktiviert, bis SMTP/i;
|
||||
|
||||
/**
|
||||
* Settings Page E2E Tests
|
||||
*
|
||||
@@ -53,6 +56,58 @@ test.describe("Settings Page", () => {
|
||||
expect(await toggles.count()).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("should keep email controls disabled when settings request is forbidden", async ({ page }) => {
|
||||
await page.route("**/api/settings", async (route) => {
|
||||
if (route.request().method() !== "GET") {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 403,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "Forbidden", code: "FORBIDDEN" }),
|
||||
});
|
||||
});
|
||||
|
||||
await navigateTo(page, "/settings");
|
||||
|
||||
const emailSection = page
|
||||
.locator(".setting-section")
|
||||
.filter({ has: page.locator(".section-header h3").filter({ hasText: emailHeadingPattern }) })
|
||||
.first();
|
||||
const emailToggle = emailSection.locator('input[type="checkbox"]').first();
|
||||
|
||||
await expect(emailToggle).toBeDisabled();
|
||||
await expect(emailSection.getByText(smtpUnavailablePattern)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("should keep the email toggle enabled when the settings API returns smtp configuration", async ({ page }) => {
|
||||
await navigateTo(page, "/settings");
|
||||
|
||||
const settingsResponse = await page.evaluate(async () => {
|
||||
const response = await fetch("/api/settings", { credentials: "include" });
|
||||
const body = await response.json().catch(() => null);
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
body,
|
||||
};
|
||||
});
|
||||
|
||||
test.skip(!settingsResponse.ok, `Settings request failed with status ${settingsResponse.status}`);
|
||||
test.skip(!settingsResponse.body?.smtpHost, "SMTP is not configured in this environment");
|
||||
|
||||
const emailSection = page
|
||||
.locator(".setting-section")
|
||||
.filter({ has: page.locator(".section-header h3").filter({ hasText: emailHeadingPattern }) })
|
||||
.first();
|
||||
const emailToggle = emailSection.locator('input[type="checkbox"]').first();
|
||||
|
||||
await expect(emailToggle).toBeEnabled();
|
||||
await expect(emailSection.getByText(smtpUnavailablePattern)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("should show stock settings section with threshold inputs", async ({ page }) => {
|
||||
await navigateTo(page, "/settings");
|
||||
|
||||
@@ -104,6 +159,22 @@ test.describe("Settings Page", () => {
|
||||
await expect(exportButton).toBeVisible();
|
||||
});
|
||||
|
||||
test("should generate a new API key from the settings page", async ({ page }) => {
|
||||
await navigateTo(page, "/settings");
|
||||
|
||||
const generateButton = page.getByRole("button", { name: /Generate key|Key erzeugen/i });
|
||||
test.skip(
|
||||
!(await generateButton.isVisible().catch(() => false)),
|
||||
"API key action is unavailable in this environment"
|
||||
);
|
||||
|
||||
await generateButton.click();
|
||||
|
||||
const tokenInput = page.locator(".api-key-token-input");
|
||||
await expect(tokenInput).toBeVisible();
|
||||
await expect(tokenInput).toHaveValue(/^ma_/);
|
||||
});
|
||||
|
||||
test("should show export/import section", async ({ page }) => {
|
||||
await navigateTo(page, "/settings");
|
||||
|
||||
|
||||
@@ -96,6 +96,10 @@ test.describe("Share Schedule", () => {
|
||||
|
||||
test("should open share dialog with person list", async ({ page }) => {
|
||||
await navigateTo(page, "/dashboard");
|
||||
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||
await expect(overviewTable.getByText(MED_ALICE)).toBeVisible({ timeout: 10000 });
|
||||
await expect(overviewTable.getByText(MED_BOB)).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click the share button
|
||||
const shareBtn = page.locator("button.share-btn");
|
||||
@@ -136,7 +140,7 @@ test.describe("Share Schedule", () => {
|
||||
await generateBtn.click();
|
||||
|
||||
// Wait for link to be generated
|
||||
const shareLinkInput = modal.locator("input.share-link-input");
|
||||
const shareLinkInput = modal.locator("input.share-link-input").first();
|
||||
await expect(shareLinkInput).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// The share link should contain /share/
|
||||
@@ -144,7 +148,7 @@ test.describe("Share Schedule", () => {
|
||||
expect(linkValue).toContain("/share/");
|
||||
|
||||
// Copy button should be visible
|
||||
await expect(modal.locator("button.btn-copy")).toBeVisible();
|
||||
await expect(modal.locator("button.btn-copy").first()).toBeVisible();
|
||||
|
||||
// Close
|
||||
await page.locator("button.modal-close").click();
|
||||
@@ -178,18 +182,19 @@ test.describe("Share Schedule", () => {
|
||||
|
||||
await page.goto(`/share/${shareToken.token}`);
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Wait for page content to load
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
|
||||
const sharedSchedule = page.locator(".shared-schedule-container");
|
||||
await expect(sharedSchedule).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// The page should show Alice's medication name
|
||||
const content = page.getByText(MED_ALICE);
|
||||
const content = sharedSchedule.getByText(MED_ALICE);
|
||||
try {
|
||||
await expect(content).toBeVisible({ timeout: 10000 });
|
||||
} catch {
|
||||
// Reload and retry — sometimes the initial load misses
|
||||
await page.reload();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
|
||||
await expect(content).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
});
|
||||
@@ -226,27 +231,32 @@ test.describe("Share Schedule", () => {
|
||||
// Visit Alice's share — should show Alice's med
|
||||
await page.goto(`/share/${aliceToken.token}`);
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
|
||||
const sharedSchedule = page.locator(".shared-schedule-container");
|
||||
await expect(sharedSchedule).toBeVisible({ timeout: 10000 });
|
||||
|
||||
try {
|
||||
await expect(page.getByText(MED_ALICE)).toBeVisible({ timeout: 10000 });
|
||||
await expect(sharedSchedule.getByText(MED_ALICE)).toBeVisible({ timeout: 10000 });
|
||||
} catch {
|
||||
await page.reload();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.getByText(MED_ALICE)).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
|
||||
await expect(sharedSchedule.getByText(MED_ALICE)).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
|
||||
// Visit Bob's share — should show Bob's med
|
||||
await page.goto(`/share/${bobToken.token}`);
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
|
||||
await expect(sharedSchedule).toBeVisible({ timeout: 10000 });
|
||||
|
||||
try {
|
||||
await expect(page.getByText(MED_BOB)).toBeVisible({ timeout: 10000 });
|
||||
await expect(sharedSchedule.getByText(MED_BOB)).toBeVisible({ timeout: 10000 });
|
||||
} catch {
|
||||
await page.reload();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.getByText(MED_BOB)).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
|
||||
await expect(sharedSchedule.getByText(MED_BOB)).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -141,6 +141,7 @@ test.describe("Stock Status Levels", () => {
|
||||
|
||||
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||
await expect(overviewTable.getByText(MED_HIGH)).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// High stock med row should have a .status-chip.high
|
||||
const highRow = overviewTable.locator(".table-row").filter({ hasText: MED_HIGH });
|
||||
@@ -199,15 +200,17 @@ test.describe("Stock Status Levels", () => {
|
||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// High stock should show many days (around 299)
|
||||
await expect(overviewTable.getByText(MED_HIGH)).toBeVisible({ timeout: 10000 });
|
||||
const highRow = overviewTable.locator(".table-row").filter({ hasText: MED_HIGH });
|
||||
const highRowText = await highRow.textContent();
|
||||
const highRowText = (await highRow.textContent()) ?? "";
|
||||
// Should contain a 3-digit number for days
|
||||
expect(highRowText).toMatch(/\d{2,3}/);
|
||||
|
||||
// Depleted should show 0 or very low number
|
||||
// Depleted rows can now show either explicit zero days left or an em dash placeholder.
|
||||
await expect(overviewTable.getByText(MED_DEPLETED)).toBeVisible({ timeout: 10000 });
|
||||
const depletedRow = overviewTable.locator(".table-row").filter({ hasText: MED_DEPLETED });
|
||||
const depletedText = await depletedRow.textContent();
|
||||
expect(depletedText).toContain("0");
|
||||
const depletedText = (await depletedRow.textContent()) ?? "";
|
||||
expect(depletedText.includes("0") || depletedText.includes("—")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("should show reorder reminder card with low-stock medications", async ({ page }) => {
|
||||
|
||||
@@ -26,5 +26,8 @@ export default defineConfig({
|
||||
rewrite: (path) => path.replace(/^\/api/, ""),
|
||||
},
|
||||
},
|
||||
// On macOS Docker volume mounts, inotify events don't reach the
|
||||
// Linux container reliably. Polling ensures HMR sees file edits.
|
||||
watch: existsSync("/.dockerenv") ? { usePolling: true, interval: 300 } : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user