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:
Daniel Volz
2026-02-25 22:15:38 +01:00
committed by GitHub
parent dbdf3b61cb
commit d02f16af3a
11 changed files with 73 additions and 17 deletions
+2
View File
@@ -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
+3 -1
View File
@@ -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)) {
+24
View File
@@ -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);
},
+2
View File
@@ -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",
+3 -1
View File
@@ -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"]],
+8 -2
View File
@@ -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 */}
+7 -2
View File
@@ -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}
+3 -1
View File
@@ -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("");
+4 -2
View File
@@ -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"
+15 -7
View File
@@ -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),
});
}
}}
>
+2 -1
View File
@@ -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