fix: stabilize e2e CI and local playwright workers (#321)
* fix: stabilize e2e CI and local playwright workers * fix(ci): apply biome formatting and import order for frontend build
This commit is contained in:
@@ -50,6 +50,8 @@ jobs:
|
||||
run: npx playwright test --project=chromium
|
||||
env:
|
||||
CI: true
|
||||
PLAYWRIGHT_WORKERS: 1
|
||||
PLAYWRIGHT_HTML_OPEN: never
|
||||
JWT_SECRET: e2e-test-secret-that-is-long-enough
|
||||
SESSION_SECRET: e2e-test-session-secret-long-enough
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { expect, test as setup } from "@playwright/test";
|
||||
import { TEST_USER } from "./fixtures";
|
||||
import { applyVideoSafetyMode, TEST_USER } from "./fixtures";
|
||||
|
||||
const authFile = path.join(import.meta.dirname, ".auth", "user.json");
|
||||
|
||||
@@ -33,6 +33,8 @@ function isTokenValid(token: string): boolean {
|
||||
* 4. Log in via the UI.
|
||||
*/
|
||||
setup("authenticate", async ({ page }) => {
|
||||
await applyVideoSafetyMode(page);
|
||||
|
||||
// Create .auth directory if it doesn't exist
|
||||
const authDir = path.dirname(authFile);
|
||||
if (!fs.existsSync(authDir)) {
|
||||
|
||||
@@ -60,6 +60,29 @@ async function setupAuthMeMock(page: Page): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce visual flashing in recorded videos by forcing a dark first paint and
|
||||
* disabling most animations/transitions in test mode.
|
||||
*/
|
||||
export async function applyVideoSafetyMode(page: Page): Promise<void> {
|
||||
await page.emulateMedia({ reducedMotion: "reduce", colorScheme: "dark" });
|
||||
await page.addInitScript(() => {
|
||||
const style = document.createElement("style");
|
||||
style.id = "pw-video-safety-style";
|
||||
style.textContent = `
|
||||
html, body {
|
||||
background: #111111 !important;
|
||||
color-scheme: dark !important;
|
||||
}
|
||||
*, *::before, *::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
`;
|
||||
document.documentElement.appendChild(style);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended test fixture that automatically mocks /auth/me on every page
|
||||
* using user data from the JWT in the stored auth file.
|
||||
@@ -72,6 +95,7 @@ async function setupAuthMeMock(page: Page): Promise<void> {
|
||||
*/
|
||||
export const test = base.extend<object>({
|
||||
page: async ({ page }, use) => {
|
||||
await applyVideoSafetyMode(page);
|
||||
await setupAuthMeMock(page);
|
||||
await use(page);
|
||||
},
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "rm -rf test-results && playwright test --config=playwright.stable.config.ts",
|
||||
"test:e2e:all": "rm -rf test-results && playwright test --config=playwright.all.config.ts",
|
||||
"test:e2e:local": "PLAYWRIGHT_HTML_OPEN=never PLAYWRIGHT_WORKERS=4 npm run test:e2e",
|
||||
"test:e2e:all:local": "PLAYWRIGHT_HTML_OPEN=never PLAYWRIGHT_WORKERS=4 npm run test:e2e:all",
|
||||
"test:e2e:with-video": "npm run test:e2e && npm run test:e2e:video",
|
||||
"test:e2e:all:with-video": "npm run test:e2e:all && npm run test:e2e:video",
|
||||
"test:e2e:video": "find \"$PWD/test-results\" -name video.webm -not -path '*retry*' -print0 | xargs -0 ls -tr > /tmp/e2e-videos.list && if [ -s /tmp/e2e-videos.list ]; then sed \"s/^/file '/\" /tmp/e2e-videos.list | sed \"s/$/'/\" > /tmp/e2e-videos.txt && ffmpeg -y -f concat -safe 0 -i /tmp/e2e-videos.txt -c copy test-results/all-tests.webm; else echo 'No videos found to merge'; fi",
|
||||
|
||||
@@ -6,6 +6,8 @@ export function buildPlaywrightConfig(runAllBrowsers: boolean) {
|
||||
? ((globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env ?? {})
|
||||
: {};
|
||||
const baseURL = env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
||||
const parsedWorkers = Number.parseInt(env.PLAYWRIGHT_WORKERS ?? "", 10);
|
||||
const workers = Number.isFinite(parsedWorkers) && parsedWorkers > 0 ? parsedWorkers : env.CI ? 1 : 4;
|
||||
|
||||
const projects: NonNullable<PlaywrightTestConfig["projects"]> = [
|
||||
{
|
||||
@@ -64,7 +66,7 @@ export function buildPlaywrightConfig(runAllBrowsers: boolean) {
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!env.CI,
|
||||
retries: env.CI ? 2 : 0,
|
||||
workers: 1,
|
||||
workers,
|
||||
reporter: env.CI
|
||||
? [["html", { outputFolder: "playwright-report" }], ["github"]]
|
||||
: [["html", { outputFolder: "playwright-report" }], ["list"]],
|
||||
|
||||
@@ -672,7 +672,9 @@ export function MedDetailModal({
|
||||
</div>
|
||||
<div className="med-detail-titles">
|
||||
<h2>{getMedDisplayName(selectedMed)}</h2>
|
||||
{selectedMed.name && selectedMed.genericName && <span className="med-generic-name">{selectedMed.genericName}</span>}
|
||||
{selectedMed.name && selectedMed.genericName && (
|
||||
<span className="med-generic-name">{selectedMed.genericName}</span>
|
||||
)}
|
||||
{selectedMed.takenBy && (selectedMed.takenBy || []).length > 0 && (
|
||||
<span className="med-taken-by">
|
||||
{t("modal.for")}{" "}
|
||||
@@ -1017,7 +1019,11 @@ export function MedDetailModal({
|
||||
|
||||
{/* Image Lightbox */}
|
||||
{showImageLightbox && selectedMed.imageUrl && (
|
||||
<Lightbox src={`/api/images/${selectedMed.imageUrl}`} alt={getMedDisplayName(selectedMed)} onClose={onCloseImageLightbox} />
|
||||
<Lightbox
|
||||
src={`/api/images/${selectedMed.imageUrl}`}
|
||||
alt={getMedDisplayName(selectedMed)}
|
||||
onClose={onCloseImageLightbox}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Refill Modal */}
|
||||
|
||||
@@ -253,7 +253,10 @@ export function MobileEditModal({
|
||||
const mobileTitle = (() => {
|
||||
if (!editingId) return t("form.newEntry");
|
||||
if (readOnlyMode) return t("form.viewEntry");
|
||||
const medicationName = (currentMed ? (currentMed.name?.trim() || currentMed.genericName?.trim()) : null) || form.name.trim() || form.genericName.trim();
|
||||
const medicationName =
|
||||
(currentMed ? currentMed.name?.trim() || currentMed.genericName?.trim() : null) ||
|
||||
form.name.trim() ||
|
||||
form.genericName.trim();
|
||||
if (!medicationName) return t("form.editEntry");
|
||||
return t("form.editEntryWithName", { name: medicationName });
|
||||
})();
|
||||
@@ -366,7 +369,9 @@ export function MobileEditModal({
|
||||
<span className="field-error">{fieldErrors.name}</span>
|
||||
)}
|
||||
</label>
|
||||
<label className={`full ${!readOnlyMode && showNameValidation && fieldErrors.genericName ? "has-error" : ""}`}>
|
||||
<label
|
||||
className={`full ${!readOnlyMode && showNameValidation && fieldErrors.genericName ? "has-error" : ""}`}
|
||||
>
|
||||
{t("form.genericName")}
|
||||
<input
|
||||
value={form.genericName}
|
||||
|
||||
@@ -320,7 +320,9 @@ function generateTextReport(
|
||||
for (const med of meds) {
|
||||
lines.push(sep);
|
||||
lines.push("");
|
||||
const title = med.isObsolete ? `${getMedDisplayName(med)} (${t("report.docStatusObsolete")})` : getMedDisplayName(med);
|
||||
const title = med.isObsolete
|
||||
? `${getMedDisplayName(med)} (${t("report.docStatusObsolete")})`
|
||||
: getMedDisplayName(med);
|
||||
lines.push(h2(title));
|
||||
lines.push("");
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ import { ConfirmModal, MedicationAvatar } from "../components";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import { useAppContext } from "../context";
|
||||
import { useModalHistory } from "../hooks";
|
||||
import { getMedDisplayName } from "../types";
|
||||
import { formatNumber, getExpiryClass, getSystemLocale } from "../utils/formatters";
|
||||
import { expandDoseIds, getStockStatus, isDoseDismissed } from "../utils/schedule";
|
||||
import { getMedDisplayName } from "../types";
|
||||
import {
|
||||
formatFullBlisters,
|
||||
formatOpenBlisterAndLoose,
|
||||
@@ -354,7 +354,9 @@ export function DashboardPage() {
|
||||
<span className="reminder-status-value">
|
||||
{reminderData.lastIntakeSent.medName &&
|
||||
(() => {
|
||||
const medication = meds.find((m) => getMedDisplayName(m) === reminderData.lastIntakeSent!.medName);
|
||||
const medication = meds.find(
|
||||
(m) => getMedDisplayName(m) === reminderData.lastIntakeSent!.medName
|
||||
);
|
||||
return medication ? (
|
||||
<span
|
||||
className="med-link clickable"
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useAuth } from "../components/Auth";
|
||||
import { useAppContext, useUnsavedChanges } from "../context";
|
||||
import { useMedicationForm, useModalHistory, useUnsavedChangesWarning } from "../hooks";
|
||||
import type { DoseUnit, Medication } from "../types";
|
||||
import { DOSE_UNITS, FIELD_LIMITS, getPackageSize, getMedDisplayName } from "../types";
|
||||
import { DOSE_UNITS, FIELD_LIMITS, getMedDisplayName, getPackageSize } from "../types";
|
||||
import { combineDateAndTime, formatDate, formatDateTime, formatNumber } from "../utils/formatters";
|
||||
import { MAX_IMAGE_UPLOAD_BYTES, resolveImageUploadError } from "../utils/image-upload";
|
||||
import { log } from "../utils/logger";
|
||||
@@ -836,11 +836,13 @@ export function MedicationsPage() {
|
||||
<span
|
||||
className={med.imageUrl ? "med-avatar-clickable" : undefined}
|
||||
onClick={() =>
|
||||
med.imageUrl && setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: getMedDisplayName(med) })
|
||||
med.imageUrl &&
|
||||
setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: getMedDisplayName(med) })
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
if (med.imageUrl) setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: getMedDisplayName(med) });
|
||||
if (med.imageUrl)
|
||||
setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: getMedDisplayName(med) });
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -910,8 +912,10 @@ export function MedicationsPage() {
|
||||
)}
|
||||
<div className="med-total">
|
||||
{t("medications.details.stock")}:{" "}
|
||||
{coverageByMed[getMedDisplayName(med)] ? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft) : getPackageSize(med)} /{" "}
|
||||
{getPackageSize(med)} {getPackageSize(med) === 1 ? t("common.pill") : t("common.pills")}
|
||||
{coverageByMed[getMedDisplayName(med)]
|
||||
? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft)
|
||||
: getPackageSize(med)}{" "}
|
||||
/ {getPackageSize(med)} {getPackageSize(med) === 1 ? t("common.pill") : t("common.pills")}
|
||||
{(coverageByMed[getMedDisplayName(med)]
|
||||
? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft)
|
||||
: getPackageSize(med)) > getPackageSize(med) && (
|
||||
@@ -970,12 +974,16 @@ export function MedicationsPage() {
|
||||
<span
|
||||
className={med.imageUrl ? "med-avatar-clickable" : undefined}
|
||||
onClick={() =>
|
||||
med.imageUrl && setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: getMedDisplayName(med) })
|
||||
med.imageUrl &&
|
||||
setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: getMedDisplayName(med) })
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
if (med.imageUrl)
|
||||
setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: getMedDisplayName(med) });
|
||||
setLightboxImage({
|
||||
src: `/api/images/${med.imageUrl}`,
|
||||
alt: getMedDisplayName(med),
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -205,7 +205,8 @@ export function PlannerPage() {
|
||||
</div>
|
||||
{plannerRows.map((row) => {
|
||||
const med =
|
||||
meds.find((m) => m.id === row.medicationId) || meds.find((m) => getMedDisplayName(m) === row.medicationName);
|
||||
meds.find((m) => m.id === row.medicationId) ||
|
||||
meds.find((m) => getMedDisplayName(m) === row.medicationName);
|
||||
const remainingRefills = med?.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? 0) : null;
|
||||
return (
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user