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
|
PORT=3000
|
||||||
CORS_ORIGINS=http://localhost:4174
|
CORS_ORIGINS=http://localhost:4174
|
||||||
LOG_LEVEL=warn
|
LOG_LEVEL=warn
|
||||||
|
|
||||||
# Levels: debug, info, warn, error, silent
|
# Levels: debug, info, warn, error, silent
|
||||||
# Controls: backend Fastify logging, frontend nginx access logs (Docker),
|
# Controls: backend Fastify logging, frontend nginx access logs (Docker),
|
||||||
# and frontend browser console (via build-time injection)
|
# and frontend browser console (via build-time injection)
|
||||||
@@ -28,6 +29,10 @@ LOG_LEVEL=warn
|
|||||||
# Increase for development/testing environments
|
# Increase for development/testing environments
|
||||||
# RATE_LIMIT_MAX=100
|
# 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)
|
# Timezone for scheduled reminders (e.g., Europe/Berlin, America/New_York)
|
||||||
TZ=Europe/Berlin
|
TZ=Europe/Berlin
|
||||||
|
|
||||||
|
|||||||
Vendored
+67
-48
@@ -1,49 +1,68 @@
|
|||||||
{
|
{
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{
|
{
|
||||||
"label": "E2E stable",
|
"label": "E2E stable",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "npm",
|
"command": "npm",
|
||||||
"args": ["run", "test:e2e"],
|
"args": [
|
||||||
"options": {
|
"run",
|
||||||
"cwd": "${workspaceFolder}/frontend"
|
"test:e2e"
|
||||||
},
|
],
|
||||||
"group": "test",
|
"options": {
|
||||||
"problemMatcher": []
|
"cwd": "${workspaceFolder}/frontend"
|
||||||
},
|
},
|
||||||
{
|
"group": "test",
|
||||||
"label": "E2E stable + merged video",
|
"problemMatcher": []
|
||||||
"type": "shell",
|
},
|
||||||
"command": "npm",
|
{
|
||||||
"args": ["run", "test:e2e:with-video"],
|
"label": "E2E stable + merged video",
|
||||||
"options": {
|
"type": "shell",
|
||||||
"cwd": "${workspaceFolder}/frontend"
|
"command": "npm",
|
||||||
},
|
"args": [
|
||||||
"group": "test",
|
"run",
|
||||||
"problemMatcher": []
|
"test:e2e:with-video"
|
||||||
},
|
],
|
||||||
{
|
"options": {
|
||||||
"label": "E2E all browsers",
|
"cwd": "${workspaceFolder}/frontend"
|
||||||
"type": "shell",
|
},
|
||||||
"command": "npm",
|
"group": "test",
|
||||||
"args": ["run", "test:e2e:all"],
|
"problemMatcher": []
|
||||||
"options": {
|
},
|
||||||
"cwd": "${workspaceFolder}/frontend"
|
{
|
||||||
},
|
"label": "E2E all browsers",
|
||||||
"group": "test",
|
"type": "shell",
|
||||||
"problemMatcher": []
|
"command": "npm",
|
||||||
},
|
"args": [
|
||||||
{
|
"run",
|
||||||
"label": "E2E all browsers + merged video",
|
"test:e2e:all"
|
||||||
"type": "shell",
|
],
|
||||||
"command": "npm",
|
"options": {
|
||||||
"args": ["run", "test:e2e:all:with-video"],
|
"cwd": "${workspaceFolder}/frontend"
|
||||||
"options": {
|
},
|
||||||
"cwd": "${workspaceFolder}/frontend"
|
"group": "test",
|
||||||
},
|
"problemMatcher": []
|
||||||
"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
|
git clone https://github.com/DanielVolz/medassist-ng.git
|
||||||
cd medassist-ng
|
cd medassist-ng
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
docker compose up -d
|
docker compose -p medassist-ng up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:4174` and start tracking your medications.
|
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 |
|
| `PORT` | `3000` | Backend API port |
|
||||||
| `CORS_ORIGINS` | `http://localhost:4174` | Allowed origins for CORS |
|
| `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. |
|
| `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 |
|
| `TZ` | `Europe/Berlin` | Timezone for scheduled reminders |
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
@@ -211,6 +212,42 @@ All configuration is done via environment variables in `.env`. Copy `.env.exampl
|
|||||||
|
|
||||||
Generate secrets with: `openssl rand -hex 32`
|
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
|
### OIDC / SSO
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
@@ -309,30 +346,22 @@ For all services and options, see the [Shoutrrr documentation](https://containrr
|
|||||||
# Development
|
# Development
|
||||||
|
|
||||||
```bash
|
```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)
|
- Frontend: `http://localhost:5173` (hot reload)
|
||||||
- Backend: `http://localhost:3000`
|
- 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
|
```bash
|
||||||
cd frontend
|
npm run lint
|
||||||
npm run test:e2e:local # local run with PLAYWRIGHT_WORKERS=4
|
cd backend && npm run test:run
|
||||||
npm run test:e2e:all:local # local all-browser run with PLAYWRIGHT_WORKERS=4
|
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
|
# Acknowledgements
|
||||||
|
|
||||||
This project was inspired by [MedAssist](https://github.com/njic/medassist) by njic.
|
This project was inspired by [MedAssist](https://github.com/njic/medassist) by njic.
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
name: medassist-dev
|
||||||
|
|
||||||
services:
|
services:
|
||||||
backend-dev:
|
backend-dev:
|
||||||
image: node:22-slim
|
image: node:22-slim
|
||||||
@@ -10,6 +12,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
- NODE_ENV=development
|
||||||
- DATA_DIR=/app/data
|
- DATA_DIR=/app/data
|
||||||
- RATE_LIMIT_MAX=1000
|
- RATE_LIMIT_MAX=1000
|
||||||
ports:
|
ports:
|
||||||
@@ -33,6 +36,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
- NODE_ENV=development
|
||||||
- BACKEND_URL=http://backend-dev:3000
|
- BACKEND_URL=http://backend-dev:3000
|
||||||
ports:
|
ports:
|
||||||
- "5173:5173"
|
- "5173:5173"
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
name: medassist-ng
|
||||||
|
|
||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
image: ghcr.io/danielvolz/medassist-ng-backend:latest
|
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 }) => {
|
test("should show day summary with dose progress", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
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");
|
const todayBlock = page.locator(".day-block.today");
|
||||||
await expect(todayBlock).toBeVisible({ timeout: 10000 });
|
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)));
|
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
||||||
continue;
|
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) {
|
if (!res.ok) {
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
throw new Error(`Failed to create share token: ${res.status} ${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();
|
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");
|
test.skip(!(await takeBtn.isVisible().catch(() => false)), "No actionable take-dose button is visible for today");
|
||||||
|
|
||||||
await takeBtn.click();
|
await Promise.all([
|
||||||
await page.waitForLoadState("networkidle");
|
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 });
|
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 }) => {
|
test("should display share button in schedules section", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
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");
|
const shareBtn = page.locator("button.share-btn");
|
||||||
await expect(shareBtn).toBeVisible();
|
await expect(shareBtn).toBeVisible();
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { expect } from "@playwright/test";
|
import { expect } from "@playwright/test";
|
||||||
import { authFile, navigateTo, test } from "./fixtures";
|
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
|
* Settings Page E2E Tests
|
||||||
*
|
*
|
||||||
@@ -53,6 +56,58 @@ test.describe("Settings Page", () => {
|
|||||||
expect(await toggles.count()).toBeGreaterThanOrEqual(2);
|
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 }) => {
|
test("should show stock settings section with threshold inputs", async ({ page }) => {
|
||||||
await navigateTo(page, "/settings");
|
await navigateTo(page, "/settings");
|
||||||
|
|
||||||
@@ -104,6 +159,22 @@ test.describe("Settings Page", () => {
|
|||||||
await expect(exportButton).toBeVisible();
|
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 }) => {
|
test("should show export/import section", async ({ page }) => {
|
||||||
await navigateTo(page, "/settings");
|
await navigateTo(page, "/settings");
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ test.describe("Share Schedule", () => {
|
|||||||
|
|
||||||
test("should open share dialog with person list", async ({ page }) => {
|
test("should open share dialog with person list", async ({ page }) => {
|
||||||
await navigateTo(page, "/dashboard");
|
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
|
// Click the share button
|
||||||
const shareBtn = page.locator("button.share-btn");
|
const shareBtn = page.locator("button.share-btn");
|
||||||
@@ -136,7 +140,7 @@ test.describe("Share Schedule", () => {
|
|||||||
await generateBtn.click();
|
await generateBtn.click();
|
||||||
|
|
||||||
// Wait for link to be generated
|
// 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 });
|
await expect(shareLinkInput).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// The share link should contain /share/
|
// The share link should contain /share/
|
||||||
@@ -144,7 +148,7 @@ test.describe("Share Schedule", () => {
|
|||||||
expect(linkValue).toContain("/share/");
|
expect(linkValue).toContain("/share/");
|
||||||
|
|
||||||
// Copy button should be visible
|
// Copy button should be visible
|
||||||
await expect(modal.locator("button.btn-copy")).toBeVisible();
|
await expect(modal.locator("button.btn-copy").first()).toBeVisible();
|
||||||
|
|
||||||
// Close
|
// Close
|
||||||
await page.locator("button.modal-close").click();
|
await page.locator("button.modal-close").click();
|
||||||
@@ -178,18 +182,19 @@ test.describe("Share Schedule", () => {
|
|||||||
|
|
||||||
await page.goto(`/share/${shareToken.token}`);
|
await page.goto(`/share/${shareToken.token}`);
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
|
||||||
// Wait for page content to load
|
const sharedSchedule = page.locator(".shared-schedule-container");
|
||||||
await page.waitForTimeout(2000);
|
await expect(sharedSchedule).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// The page should show Alice's medication name
|
// The page should show Alice's medication name
|
||||||
const content = page.getByText(MED_ALICE);
|
const content = sharedSchedule.getByText(MED_ALICE);
|
||||||
try {
|
try {
|
||||||
await expect(content).toBeVisible({ timeout: 10000 });
|
await expect(content).toBeVisible({ timeout: 10000 });
|
||||||
} catch {
|
} catch {
|
||||||
// Reload and retry — sometimes the initial load misses
|
// Reload and retry — sometimes the initial load misses
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
|
||||||
await expect(content).toBeVisible({ 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
|
// Visit Alice's share — should show Alice's med
|
||||||
await page.goto(`/share/${aliceToken.token}`);
|
await page.goto(`/share/${aliceToken.token}`);
|
||||||
await page.waitForLoadState("networkidle");
|
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 {
|
try {
|
||||||
await expect(page.getByText(MED_ALICE)).toBeVisible({ timeout: 10000 });
|
await expect(sharedSchedule.getByText(MED_ALICE)).toBeVisible({ timeout: 10000 });
|
||||||
} catch {
|
} catch {
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await page.waitForLoadState("networkidle");
|
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
|
// Visit Bob's share — should show Bob's med
|
||||||
await page.goto(`/share/${bobToken.token}`);
|
await page.goto(`/share/${bobToken.token}`);
|
||||||
await page.waitForLoadState("networkidle");
|
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 {
|
try {
|
||||||
await expect(page.getByText(MED_BOB)).toBeVisible({ timeout: 10000 });
|
await expect(sharedSchedule.getByText(MED_BOB)).toBeVisible({ timeout: 10000 });
|
||||||
} catch {
|
} catch {
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await page.waitForLoadState("networkidle");
|
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();
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
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
|
// High stock med row should have a .status-chip.high
|
||||||
const highRow = overviewTable.locator(".table-row").filter({ hasText: MED_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 });
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// High stock should show many days (around 299)
|
// 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 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
|
// Should contain a 3-digit number for days
|
||||||
expect(highRowText).toMatch(/\d{2,3}/);
|
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 depletedRow = overviewTable.locator(".table-row").filter({ hasText: MED_DEPLETED });
|
||||||
const depletedText = await depletedRow.textContent();
|
const depletedText = (await depletedRow.textContent()) ?? "";
|
||||||
expect(depletedText).toContain("0");
|
expect(depletedText.includes("0") || depletedText.includes("—")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show reorder reminder card with low-stock medications", async ({ page }) => {
|
test("should show reorder reminder card with low-stock medications", async ({ page }) => {
|
||||||
|
|||||||
@@ -26,5 +26,8 @@ export default defineConfig({
|
|||||||
rewrite: (path) => path.replace(/^\/api/, ""),
|
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