fix: unblock PR 502 checks

* build(deps-dev): bump typescript from 5.9.3 to 6.0.2 in /backend

Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix: unblock PR 502 checks

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Daniel Volz <mail@danielvolz.org>
This commit is contained in:
dependabot[bot]
2026-03-30 20:14:29 +02:00
committed by GitHub
parent 8a9b44ef31
commit bd2bfe6972
7 changed files with 350 additions and 71 deletions
+4 -4
View File
@@ -38,7 +38,7 @@
"pino-pretty": "^13.1.3",
"supertest": "^7.2.2",
"tsx": "^4.19.0",
"typescript": "^5.5.4",
"typescript": "^6.0.2",
"vitest": "^4.0.16"
}
},
@@ -6240,9 +6240,9 @@
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
+1 -1
View File
@@ -47,7 +47,7 @@
"pino-pretty": "^13.1.3",
"supertest": "^7.2.2",
"tsx": "^4.19.0",
"typescript": "^5.5.4",
"typescript": "^6.0.2",
"vitest": "^4.0.16"
}
}
+1
View File
@@ -3,6 +3,7 @@
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "node",
"ignoreDeprecations": "6.0",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
+21
View File
@@ -8,11 +8,27 @@ import {
test,
} from "./fixtures";
async function isAuthEnabled(page: Parameters<Parameters<typeof test>[0]>[0]["page"]): Promise<boolean> {
try {
const response = await page.request.get("/api/auth/state");
if (!response.ok()) {
return true;
}
const state = (await response.json()) as { authEnabled?: boolean };
return state.authEnabled !== false;
} catch {
return true;
}
}
test.describe("App Shell", () => {
test.use({ storageState: authFile });
test.describe.configure({ timeout: 90000 });
test("opens and closes profile modal from user menu", async ({ page }) => {
test.skip(!(await isAuthEnabled(page)), "Auth is disabled in this environment");
await navigateTo(page, "/dashboard");
await page.locator(".user-menu-btn").click();
@@ -24,6 +40,8 @@ test.describe("App Shell", () => {
});
test("opens and closes about modal from user menu", async ({ page }) => {
test.skip(!(await isAuthEnabled(page)), "Auth is disabled in this environment");
await navigateTo(page, "/dashboard");
await page.locator(".user-menu-btn").click();
@@ -36,6 +54,8 @@ test.describe("App Shell", () => {
});
test("signs out from user menu", async ({ page }) => {
test.skip(!(await isAuthEnabled(page)), "Auth is disabled in this environment");
await navigateTo(page, "/dashboard");
await page.locator(".user-menu-btn").click();
@@ -50,6 +70,7 @@ test.describe("Public Share Routes", () => {
test.describe.configure({ timeout: 90000 });
test.beforeAll(async () => {
test.setTimeout(60000);
await deleteAllMedicationsViaAPI();
await createMedicationViaAPI({
name: "Share Overview Redirect Med",
+223 -28
View File
@@ -1,6 +1,6 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { expect, test as setup } from "@playwright/test";
import { expect, test as setup, type APIResponse, type Cookie } from "@playwright/test";
import { applyVideoSafetyMode, TEST_USER } from "./fixtures";
const authFile = path.join(import.meta.dirname, ".auth", "user.json");
@@ -21,6 +21,85 @@ function isTokenValid(token: string): boolean {
}
}
function toBrowserCookie(setCookieHeader: string, baseURL: string): Cookie | null {
const segments = setCookieHeader
.split(";")
.map((segment) => segment.trim())
.filter(Boolean);
const [nameValue, ...attributes] = segments;
if (!nameValue) {
return null;
}
const separatorIndex = nameValue.indexOf("=");
if (separatorIndex <= 0) {
return null;
}
const cookie: Cookie = {
name: nameValue.slice(0, separatorIndex),
value: nameValue.slice(separatorIndex + 1),
url: baseURL,
path: "/",
httpOnly: false,
secure: false,
sameSite: "Lax",
};
for (const attribute of attributes) {
const [rawKey, ...rawValueParts] = attribute.split("=");
const key = rawKey?.toLowerCase();
const value = rawValueParts.join("=");
switch (key) {
case "expires": {
const expiresAt = Date.parse(value);
if (!Number.isNaN(expiresAt)) {
cookie.expires = Math.floor(expiresAt / 1000);
}
break;
}
case "httponly":
cookie.httpOnly = true;
break;
case "max-age": {
const seconds = Number.parseInt(value, 10);
if (Number.isFinite(seconds)) {
cookie.expires = Math.floor(Date.now() / 1000) + seconds;
}
break;
}
case "path":
cookie.path = value || "/";
break;
case "samesite":
cookie.sameSite = /^none$/i.test(value) ? "None" : /^strict$/i.test(value) ? "Strict" : "Lax";
break;
case "secure":
cookie.secure = true;
break;
}
}
return cookie;
}
async function syncResponseCookiesToBrowserContext(
page: Parameters<Parameters<typeof setup>[0]>[0]["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);
if (cookies.length > 0) {
await page.context().addCookies(cookies);
}
}
/**
* Global setup: ensure a test user exists and persist authenticated state.
* Runs once before all test projects.
@@ -33,6 +112,7 @@ function isTokenValid(token: string): boolean {
* 4. Log in via the UI.
*/
setup("authenticate", async ({ page }) => {
setup.setTimeout(120000);
await applyVideoSafetyMode(page);
// Create .auth directory if it doesn't exist
@@ -41,37 +121,24 @@ setup("authenticate", async ({ page }) => {
fs.mkdirSync(authDir, { recursive: true });
}
// ---- 1. Try to reuse an existing auth file (offline check) ----
// ---- 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 accessCookie = saved.cookies?.find((c: { name: string }) => c.name === "access_token");
if (accessCookie?.value && isTokenValid(accessCookie.value)) {
// Token still has enough validity — skip login entirely
return;
// Keep going and verify the session online. A JWT can be time-valid but
// still rejected by backend token rotation/restart.
}
} catch {
// Invalid file — fall through to regular login
}
}
// ---- 2. Check if auth is disabled ----
// ---- 2. Fast path: already authenticated session ----
await page.goto("/");
const authDisabled = await page
.locator("header.hero")
.isVisible()
.catch(() => false);
if (authDisabled) {
await page.context().storageState({ path: authFile });
return;
}
// Wait for auth container
await expect(page.locator(".auth-container")).toBeVisible({ timeout: 15000 });
// ---- 3. Query auth state to determine login method ----
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
let authEnabled = true;
let formLoginEnabled = true;
let oidcEnabled = false;
let registrationEnabled = true;
@@ -79,16 +146,142 @@ setup("authenticate", async ({ page }) => {
const stateRes = await page.request.get(`${baseURL}/api/auth/state`);
if (stateRes.ok()) {
const state = await stateRes.json();
authEnabled = state.authEnabled === true;
formLoginEnabled = state.formLoginEnabled !== false;
oidcEnabled = state.oidcEnabled === true;
registrationEnabled = state.registrationEnabled !== false;
}
} catch {
// Fallback: assume form login is available
// Fallback: assume auth is enabled and form login is available.
}
// ---- 3. Check if auth is disabled ----
if (!authEnabled) {
await page.context().storageState({ path: authFile });
return;
}
const hasUserMenu = await page
.locator(".user-menu-btn")
.isVisible({ timeout: 5000 })
.catch(() => false);
if (hasUserMenu) {
await page.context().storageState({ path: authFile });
return;
}
const hasAuthenticatedSession = await page.request
.get(`${baseURL}/api/auth/me`)
.then((response) => response.ok())
.catch(() => false);
if (hasAuthenticatedSession) {
await page.goto("/");
await expect(page.locator(".user-menu-btn")).toBeVisible({ timeout: 15000 });
await page.context().storageState({ path: authFile });
return;
}
const hasAuthContainer = await page
.locator(".auth-container")
.isVisible({ timeout: 5000 })
.catch(() => false);
if (!hasAuthContainer) {
const hasLoginFields = await page
.locator("#username")
.isVisible({ timeout: 5000 })
.catch(() => false);
if (!hasLoginFields) {
const becameAuthenticated = await page
.locator("header.hero")
.isVisible({ timeout: 5000 })
.catch(() => false);
if (becameAuthenticated) {
await page.context().storageState({ path: authFile });
return;
}
}
}
const loginWithApi = async () => {
const res = await page.request.post(`${baseURL}/api/auth/login`, {
data: { username: TEST_USER.username, password: TEST_USER.password, rememberMe: false },
});
if (res.ok()) {
await syncResponseCookiesToBrowserContext(page, baseURL, res);
}
const bodyText = await res.text().catch(() => "");
return {
bodyText,
ok: res.ok(),
status: res.status(),
};
};
const loginWithApiRetry = async (maxAttempts = 5) => {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const result = await loginWithApi();
if (result.ok) {
return true;
}
const isRateLimited = result.status === 429 || /too many attempts/i.test(result.bodyText);
if (!isRateLimited || attempt === maxAttempts) {
return false;
}
await page.waitForTimeout(1000 * attempt);
}
return false;
};
const registerWithApi = async () => {
await page.request
.post(`${baseURL}/api/auth/register`, {
data: { username: TEST_USER.username, password: TEST_USER.password },
})
.catch(() => {});
};
const ensureAuthenticated = async () => {
const hasHeader = await page
.locator("header.hero")
.isVisible({ timeout: 8000 })
.catch(() => false);
if (hasHeader) return true;
const meRes = await page.request.get(`${baseURL}/api/auth/me`).catch(() => null);
return Boolean(meRes?.ok());
};
const hasBrowserAccessCookie = async () => {
const cookies = await page.context().cookies(baseURL);
return cookies.some((cookie) => cookie.name === "access_token");
};
// ---- 5. Log in via the appropriate method ----
if (formLoginEnabled) {
let loggedIn = await loginWithApiRetry();
if (!loggedIn && registrationEnabled) {
await registerWithApi();
loggedIn = await loginWithApiRetry();
}
if (loggedIn && (await hasBrowserAccessCookie())) {
await page.goto("/");
const isAuthenticated = await ensureAuthenticated();
if (!isAuthenticated) {
throw new Error("Authentication succeeded but app shell did not become ready");
}
await page.context().storageState({ path: authFile });
return;
}
// Fallback path for environments where API login flow is unavailable.
const loginWithForm = async () => {
const usernameField = page.locator("#username");
const passwordField = page.locator("#password");
@@ -114,7 +307,9 @@ setup("authenticate", async ({ page }) => {
await passwordField.fill(TEST_USER.password);
// Click the submit button (not the SSO button)
await page.locator('button.auth-submit[type="submit"]').click();
const submitButton = page.locator('button.auth-submit[type="submit"]');
await expect(submitButton).toBeEnabled({ timeout: 15000 });
await submitButton.click();
};
await loginWithForm();
@@ -124,11 +319,7 @@ setup("authenticate", async ({ page }) => {
.catch(() => false);
if (!hasHeroAfterFirstLogin && registrationEnabled) {
await page.request
.post(`${baseURL}/api/auth/register`, {
data: { username: TEST_USER.username, password: TEST_USER.password },
})
.catch(() => {});
await registerWithApi();
await loginWithForm();
}
@@ -157,8 +348,12 @@ setup("authenticate", async ({ page }) => {
throw new Error("No login method available: form login and OIDC are both disabled");
}
// Wait for successful auth app header should appear
await expect(page.locator("header.hero")).toBeVisible({ timeout: 15000 });
// Wait for successful auth. Prefer app header visibility, but allow verified
// authenticated API state for environments where shell render is delayed.
const isAuthenticated = await ensureAuthenticated();
if (!isAuthenticated) {
throw new Error("Authentication completed but no authenticated app state was detected");
}
// Persist authenticated state for all test projects
await page.context().storageState({ path: authFile });
+63 -14
View File
@@ -172,11 +172,41 @@ export async function signOut(page: Page): Promise<void> {
// Re-export expect for convenience
export { expect };
const APP_BASE = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
// Seed helpers talk to the backend directly so Vite proxy readiness does not consume
// the 30s beforeAll budget for API-created test data.
const API_BASE = process.env.PLAYWRIGHT_API_BASE_URL || "http://localhost:3000";
let cachedAuthEnabled: boolean | null = null;
async function isRuntimeAuthEnabled(): Promise<boolean> {
if (cachedAuthEnabled !== null) {
return cachedAuthEnabled;
}
try {
const response = await fetch(`${APP_BASE}/api/auth/state`);
if (!response.ok) {
cachedAuthEnabled = true;
return cachedAuthEnabled;
}
const state = (await response.json()) as { authEnabled?: boolean };
cachedAuthEnabled = state.authEnabled === true;
return cachedAuthEnabled;
} catch {
cachedAuthEnabled = true;
return cachedAuthEnabled;
}
}
async function getRuntimeApiBase(): Promise<string> {
return (await isRuntimeAuthEnabled()) ? API_BASE : `${APP_BASE}/api`;
}
// ---------------------------------------------------------------------------
// API helpers — create / delete medications via backend API
// ---------------------------------------------------------------------------
const API_BASE = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
let cachedAuthCookie: string | null = null;
function readAuthCookieFromFile(): string | null {
@@ -201,7 +231,8 @@ function extractCookieValue(setCookieHeaders: string[], name: string): string |
}
async function refreshAuthCookieViaLogin(): Promise<string | null> {
const res = await fetch(`${API_BASE}/api/auth/login`, {
const apiBase = await getRuntimeApiBase();
const res = await fetch(`${apiBase}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -231,6 +262,19 @@ function getAuthCookie(): string | null {
return cachedAuthCookie;
}
async function ensureAuthCookie(): Promise<string | null> {
if (!(await isRuntimeAuthEnabled())) {
return null;
}
const existingCookie = getAuthCookie();
if (existingCookie) {
return existingCookie;
}
return refreshAuthCookieViaLogin();
}
/** Typed medication response (subset of fields we care about) */
export interface TestMedication {
id: number;
@@ -276,7 +320,8 @@ export async function createMedicationViaAPI(data: {
takenBy?: string | null;
}[];
}): Promise<TestMedication> {
let token = getAuthCookie();
let token = await ensureAuthCookie();
const apiBase = await getRuntimeApiBase();
const packageType = data.packageType ?? "blister";
const isAmountBased = packageType === "bottle" || packageType === "tube" || packageType === "liquid_container";
let defaultMedicationForm: "capsule" | "tablet" | "liquid" | "topical" = "tablet";
@@ -314,7 +359,7 @@ export async function createMedicationViaAPI(data: {
};
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch(`${API_BASE}/api/medications`, {
const res = await fetch(`${apiBase}/medications`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -345,9 +390,10 @@ export async function createMedicationViaAPI(data: {
* Includes retry for rate-limited responses.
*/
export async function deleteMedicationViaAPI(id: number): Promise<void> {
let token = getAuthCookie();
let token = await ensureAuthCookie();
const apiBase = await getRuntimeApiBase();
for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch(`${API_BASE}/api/medications/${id}`, {
const res = await fetch(`${apiBase}/medications/${id}`, {
method: "DELETE",
headers: token ? { Cookie: `access_token=${token}` } : {},
});
@@ -368,9 +414,10 @@ export async function deleteMedicationViaAPI(id: number): Promise<void> {
* Includes retry logic for rate-limited responses.
*/
export async function deleteAllMedicationsViaAPI(): Promise<void> {
let token = getAuthCookie();
let token = await ensureAuthCookie();
const apiBase = await getRuntimeApiBase();
for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch(`${API_BASE}/api/medications`, {
const res = await fetch(`${apiBase}/medications`, {
headers: token ? { Cookie: `access_token=${token}` } : {},
});
if (res.status === 401) {
@@ -385,7 +432,7 @@ export async function deleteAllMedicationsViaAPI(): Promise<void> {
const meds = (await res.json()) as TestMedication[];
for (const med of meds) {
for (let delAttempt = 0; delAttempt < 3; delAttempt++) {
const delRes = await fetch(`${API_BASE}/api/medications/${med.id}`, {
const delRes = await fetch(`${apiBase}/medications/${med.id}`, {
method: "DELETE",
headers: token ? { Cookie: `access_token=${token}` } : {},
});
@@ -409,9 +456,10 @@ export async function deleteAllMedicationsViaAPI(): Promise<void> {
* Requires a medication with takenBy to exist first.
*/
export async function createShareTokenViaAPI(takenBy: string, scheduleDays = 30): Promise<TestShareToken> {
let token = getAuthCookie();
let token = await ensureAuthCookie();
const apiBase = await getRuntimeApiBase();
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch(`${API_BASE}/api/share`, {
const res = await fetch(`${apiBase}/share`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -449,9 +497,10 @@ export async function createShareTokenViaAPI(takenBy: string, scheduleDays = 30)
* Update user settings via the backend API.
*/
export async function updateSettingsViaAPI(settings: Record<string, unknown>): Promise<void> {
const token = getAuthCookie();
const token = await ensureAuthCookie();
const apiBase = await getRuntimeApiBase();
for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch(`${API_BASE}/api/settings`, {
const res = await fetch(`${apiBase}/settings`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
+37 -24
View File
@@ -9,6 +9,7 @@ import { authFile, createMedicationViaAPI, deleteAllMedicationsViaAPI, navigateT
*/
test.describe("Schedule Timeline", () => {
test.use({ storageState: authFile });
test.describe.configure({ timeout: 60000 });
const seededName = "Schedule Smoke Seed";
const startThreeDaysAgo = (() => {
@@ -19,7 +20,26 @@ test.describe("Schedule Timeline", () => {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
})();
async function waitForSeededScheduleData(page: Parameters<Parameters<typeof test>[0]>[0]["page"]) {
for (let attempt = 0; attempt < 5; attempt++) {
const response = await page.request.get("/api/medications").catch(() => null);
const medications = response?.ok() ? ((await response.json()) as Array<{ name?: string }>) : [];
const hasSeededMedication = medications.some((medication) => medication.name === seededName);
if (hasSeededMedication) {
await page.reload();
await page.waitForLoadState("networkidle");
return;
}
await page.waitForTimeout(1000 * (attempt + 1));
}
throw new Error(`Seeded medication ${seededName} did not become available via /api/medications`);
}
test.beforeAll(async () => {
test.setTimeout(60000);
await deleteAllMedicationsViaAPI();
await createMedicationViaAPI({
name: seededName,
@@ -39,7 +59,6 @@ test.describe("Schedule Timeline", () => {
test("should have timeline container in DOM", async ({ page }) => {
await navigateTo(page, "/dashboard");
// Timeline exists in the DOM (may be empty/hidden if no medications)
await expect(page.locator(".timeline")).toBeAttached();
});
@@ -48,8 +67,6 @@ test.describe("Schedule Timeline", () => {
const daysSelect = page.locator("select.schedule-days-select");
await expect(daysSelect).toBeVisible();
// Should offer 30, 90, 180 days
await expect(daysSelect.locator('option[value="30"]')).toBeAttached();
await expect(daysSelect.locator('option[value="90"]')).toBeAttached();
await expect(daysSelect.locator('option[value="180"]')).toBeAttached();
@@ -60,8 +77,6 @@ test.describe("Schedule Timeline", () => {
const daysSelect = page.locator("select.schedule-days-select");
const currentValue = await daysSelect.inputValue();
// Switch to a different range
const newValue = currentValue === "30" ? "90" : "30";
await daysSelect.selectOption(newValue);
await expect(daysSelect).toHaveValue(newValue);
@@ -69,20 +84,20 @@ test.describe("Schedule Timeline", () => {
test("should show past days toggle when medications exist", async ({ page }) => {
await navigateTo(page, "/dashboard");
await waitForSeededScheduleData(page);
// Past days toggle appears when there are scheduled medications
const pastToggle = page.locator(".past-days-toggle");
await expect(pastToggle).toBeVisible();
await expect(pastToggle).toBeVisible({ timeout: 20000 });
});
test("should expand/collapse past days on click", async ({ page }) => {
await navigateTo(page, "/dashboard");
await waitForSeededScheduleData(page);
const pastToggle = page.locator(".past-days-toggle");
await expect(pastToggle).toBeVisible();
await expect(pastToggle).toBeVisible({ timeout: 20000 });
const wasExpanded = await pastToggle.evaluate((el) => el.classList.contains("expanded"));
await pastToggle.click();
if (wasExpanded) {
@@ -94,16 +109,15 @@ test.describe("Schedule Timeline", () => {
test("should show future days toggle when medications exist", async ({ page }) => {
await navigateTo(page, "/dashboard");
await waitForSeededScheduleData(page);
// Future days toggle appears when there are scheduled medications
const futureToggle = page.locator(".future-days-toggle");
await expect(futureToggle).toBeVisible();
await expect(futureToggle).toBeVisible({ timeout: 20000 });
});
test("should display day blocks in timeline", async ({ page }) => {
await navigateTo(page, "/dashboard");
// With medications there should be day blocks; otherwise empty-state is expected.
const dayBlocks = page.locator(".day-block");
const dayBlockCount = await dayBlocks.count();
if (dayBlockCount === 0) {
@@ -116,33 +130,32 @@ test.describe("Schedule Timeline", () => {
test("should highlight today block", async ({ page }) => {
await navigateTo(page, "/dashboard");
// With medications, today should be highlighted
const todayBlock = page.locator(".day-block.today");
await expect(todayBlock).toBeVisible();
await expect(todayBlock).toBeVisible({ timeout: 15000 });
await expect(todayBlock.locator(".day-date")).toBeVisible();
});
test("should show day summary with progress", async ({ page }) => {
await navigateTo(page, "/dashboard");
await waitForSeededScheduleData(page);
const todayBlock = page.locator(".day-block.today");
await expect(todayBlock).toBeVisible();
const summary = todayBlock.locator(".day-summary");
await expect(summary).toBeVisible();
const summary = page.locator(".dashboard-schedules-section .timeline .day-summary").first();
await expect(summary).toBeVisible({ timeout: 20000 });
});
test("should collapse/expand a day block", async ({ page }) => {
await navigateTo(page, "/dashboard");
await waitForSeededScheduleData(page);
const todayBlock = page.locator(".day-block.today");
await expect(todayBlock).toBeVisible();
const dayDivider = todayBlock.locator(".day-divider");
await expect(page.locator(".dashboard-schedules-section .timeline")).toBeVisible();
const dayBlock = page.locator(".dashboard-schedules-section .day-block.today");
await expect(dayBlock).toBeVisible({ timeout: 20000 });
const dayDivider = dayBlock.locator(".day-divider");
await dayDivider.click();
const isCollapsed = await todayBlock.evaluate((el) => el.classList.contains("collapsed"));
const isCollapsed = await dayBlock.evaluate((el) => el.classList.contains("collapsed"));
await dayDivider.click();
const isCollapsedAfter = await todayBlock.evaluate((el) => el.classList.contains("collapsed"));
const isCollapsedAfter = await dayBlock.evaluate((el) => el.classList.contains("collapsed"));
expect(isCollapsed).not.toBe(isCollapsedAfter);
});