import { expect, test, type Page } from "@playwright/test"; import { execFileSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; const DB_WORKDIR = resolve(process.cwd(), "../../packages/db"); const PLAYWRIGHT_RUNTIME_ENV_PATH = resolve(process.cwd(), "e2e/.playwright-runtime.json"); const WEB_ENV_PATHS = [ resolve(process.cwd(), ".env.local"), resolve(process.cwd(), "../../.env.local"), resolve(process.cwd(), "../../.env"), ]; function resolveDatabaseUrl(): string { const explicitPlaywrightUrl = process.env["PLAYWRIGHT_DATABASE_URL"]; if (explicitPlaywrightUrl) { return explicitPlaywrightUrl; } if (existsSync(PLAYWRIGHT_RUNTIME_ENV_PATH)) { try { const runtimeEnv = JSON.parse(readFileSync(PLAYWRIGHT_RUNTIME_ENV_PATH, "utf8")) as { PLAYWRIGHT_DATABASE_URL?: string; DATABASE_URL?: string; }; if (runtimeEnv.PLAYWRIGHT_DATABASE_URL) { return runtimeEnv.PLAYWRIGHT_DATABASE_URL; } if (runtimeEnv.DATABASE_URL) { return runtimeEnv.DATABASE_URL; } } catch { // Fall back to env files if the runtime file is temporarily unavailable. } } for (const envPath of WEB_ENV_PATHS) { if (!existsSync(envPath)) { continue; } const envFile = readFileSync(envPath, "utf8"); for (const rawLine of envFile.split(/\r?\n/u)) { const line = rawLine.trim(); if (!line || line.startsWith("#")) { continue; } if (line.startsWith("PLAYWRIGHT_DATABASE_URL=")) { return line.slice("PLAYWRIGHT_DATABASE_URL=".length); } if (line.startsWith("DATABASE_URL_TEST=")) { return line.slice("DATABASE_URL_TEST=".length); } if (line.startsWith("DATABASE_URL=")) { return line.slice("DATABASE_URL=".length); } } } const fallbackTestUrl = process.env["DATABASE_URL_TEST"]; if (fallbackTestUrl) { return fallbackTestUrl; } const fallback = process.env["DATABASE_URL"]; if (fallback) { return fallback; } throw new Error("DATABASE_URL is not available for the Playwright timeline test."); } function runDbJson(body: string): T { const script = ` import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient({ datasources: { db: { url: process.env.DATABASE_URL, }, }, }); try { ${body} } finally { await prisma.$disconnect(); } `; const output = execFileSync("node", ["--input-type=module", "-e", script], { cwd: DB_WORKDIR, env: { ...process.env, DATABASE_URL: resolveDatabaseUrl(), }, encoding: "utf8", }).trim(); return JSON.parse(output) as T; } type TimelineSegmentScenario = { assignmentId: string; projectId: string; projectName: string; projectShortCode: string; resourceId: string; resourceEid: string; }; function createTimelineSegmentScenario(suffix: string): TimelineSegmentScenario { return runDbJson(` const availability = { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0, }; const resource = await prisma.resource.create({ data: { eid: ${JSON.stringify(`e2e.timeline.${suffix}`)}, displayName: ${JSON.stringify(`E2E Timeline ${suffix}`)}, email: ${JSON.stringify(`e2e.timeline.${suffix}@capakraken.dev`)}, chapter: "E2E", lcrCents: 5000, ucrCents: 9000, availability, skills: [], dynamicFields: {}, resourceType: "EMPLOYEE", chgResponsibility: true, rolledOff: false, departed: false, fte: 1, }, select: { id: true, eid: true }, }); const project = await prisma.project.create({ data: { shortCode: ${JSON.stringify(`E2ETL${suffix.slice(-6).toUpperCase()}`)}, name: ${JSON.stringify(`E2E Timeline Project ${suffix}`)}, orderType: "CHARGEABLE", allocationType: "EXT", budgetCents: 2500000, startDate: new Date("2026-04-01T00:00:00.000Z"), endDate: new Date("2026-04-30T00:00:00.000Z"), status: "ACTIVE", staffingReqs: [], dynamicFields: {}, }, select: { id: true, name: true, shortCode: true }, }); const assignment = await prisma.assignment.create({ data: { resourceId: resource.id, projectId: project.id, startDate: new Date("2026-04-06T00:00:00.000Z"), endDate: new Date("2026-04-17T00:00:00.000Z"), hoursPerDay: 8, percentage: 100, role: "E2E Role", dailyCostCents: 40000, status: "ACTIVE", metadata: {}, }, select: { id: true }, }); console.log(JSON.stringify({ assignmentId: assignment.id, projectId: project.id, projectName: project.name, projectShortCode: project.shortCode, resourceId: resource.id, resourceEid: resource.eid, })); `); } function cleanupTimelineSegmentScenario(projectId: string, resourceId: string) { runDbJson(` await prisma.assignment.deleteMany({ where: { projectId: ${JSON.stringify(projectId)} }, }); await prisma.project.deleteMany({ where: { id: ${JSON.stringify(projectId)} }, }); await prisma.resource.deleteMany({ where: { id: ${JSON.stringify(resourceId)} }, }); console.log("null"); `); } function listScenarioAssignments(projectId: string) { return runDbJson>(` const assignments = await prisma.assignment.findMany({ where: { projectId: ${JSON.stringify(projectId)} }, orderBy: [{ startDate: "asc" }, { endDate: "asc" }], select: { id: true, startDate: true, endDate: true }, }); console.log(JSON.stringify( assignments.map((entry) => ({ id: entry.id, startDate: entry.startDate.toISOString().slice(0, 10), endDate: entry.endDate.toISOString().slice(0, 10), })), )); `); } function readScenarioSnapshot(projectId: string, resourceId: string, resourceEid: string) { return runDbJson<{ resource: { id: string; eid: string; displayName: string } | null; project: { id: string; name: string; shortCode: string } | null; assignments: Array<{ id: string; startDate: string; endDate: string; status: string }>; }>(` const [resource, project, assignments] = await Promise.all([ prisma.resource.findUnique({ where: { id: ${JSON.stringify(resourceId)} }, select: { id: true, eid: true, displayName: true }, }), prisma.project.findUnique({ where: { id: ${JSON.stringify(projectId)} }, select: { id: true, name: true, shortCode: true }, }), prisma.assignment.findMany({ where: { projectId: ${JSON.stringify(projectId)}, resourceId: ${JSON.stringify(resourceId)}, }, orderBy: [{ startDate: "asc" }, { endDate: "asc" }], select: { id: true, startDate: true, endDate: true, status: true }, }), ]); console.log(JSON.stringify({ resource: resource && resource.eid === ${JSON.stringify(resourceEid)} ? resource : resource, project, assignments: assignments.map((entry) => ({ id: entry.id, startDate: entry.startDate.toISOString().slice(0, 10), endDate: entry.endDate.toISOString().slice(0, 10), status: entry.status, })), })); `); } async function waitForScenarioAssignments( projectId: string, expected: Array<{ startDate: string; endDate: string }>, ) { await expect .poll( () => listScenarioAssignments(projectId).map((entry) => ({ startDate: entry.startDate, endDate: entry.endDate, })), { timeout: 15000 }, ) .toEqual(expected); } async function dragLocatorBy(page: Page, locator: ReturnType, deltaX: number) { const box = await locator.boundingBox(); expect(box).not.toBeNull(); if (!box) { throw new Error("Expected locator to be visible before dragging"); } const startX = box.x + box.width / 2; const startY = box.y + box.height / 2; await page.mouse.move(startX, startY); await page.mouse.down(); await page.mouse.move(startX + deltaX, startY, { steps: 8 }); await page.waitForTimeout(80); return box; } async function openAllocationContextMenuAtOffset( page: Page, locator: ReturnType, xOffset: number, ) { const box = await locator.boundingBox(); expect(box).not.toBeNull(); if (!box) { throw new Error("Expected allocation segment to be visible before context click"); } await page.mouse.click(box.x + xOffset, box.y + box.height / 2, { button: "right" }); } async function releaseMouse(page: Page) { await page.mouse.up(); await page.waitForTimeout(120); } async function selectProjectFromCombobox( page: Page, projectShortCode: string, projectName: string, ) { const projectInput = page.locator('input[placeholder="Search project…"]').last(); await projectInput.click(); await projectInput.fill(projectShortCode); const optionName = new RegExp( `${escapeRegex(projectShortCode)}\\s*.*${escapeRegex(projectName)}`, "i", ); await page.getByRole("button", { name: optionName }).last().click(); } async function dragRowSelection( page: Page, row: ReturnType, startX: number, endX: number, ) { const rowBox = await readBoundingBox(row); const centerY = rowBox.y + Math.min(rowBox.height / 2, rowBox.height - 6); await page.mouse.move(startX, centerY); await page.mouse.down(); await page.mouse.move(endX, centerY, { steps: 8 }); await page.mouse.up(); await page.waitForTimeout(120); } async function readBoundingBox(locator: ReturnType) { const box = await locator.boundingBox(); expect(box).not.toBeNull(); if (!box) { throw new Error("Expected locator to have a bounding box"); } return box; } async function listRenderedAllocationSegments( row: ReturnType, allocationId?: string, ) { const selector = allocationId ? `[data-allocation-id="${allocationId}"]` : "[data-allocation-id]"; return row.locator(selector).evaluateAll((elements) => elements.map((element) => { const htmlElement = element as HTMLElement; const { dataset } = htmlElement; return { allocationId: dataset.allocationId ?? null, segmentIndex: dataset.allocationSegmentIndex ?? null, segmentStart: dataset.allocationSegmentStart ?? null, segmentEnd: dataset.allocationSegmentEnd ?? null, interaction: dataset.allocationInteraction ?? null, text: htmlElement.innerText.replace(/\s+/gu, " ").trim(), }; }), ); } function escapeRegex(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } async function signInAsAdmin(page: Page) { await page.goto("/auth/signin"); await page.fill('input[type="email"]', "admin@capakraken.dev"); await page.fill('input[type="password"]', "admin123"); await page.click('button[type="submit"]'); await expect(page).toHaveURL(/\/(dashboard|resources)/); } async function findVisibleTimelineEntryId( page: Page, selector: string, minimumWidth = 24, ) { return page.locator(selector).evaluateAll((elements, minimum) => { for (const element of elements) { if (!(element instanceof HTMLElement)) continue; const id = element.dataset.allocationId ?? element.dataset.timelineProjectId; if (!id) continue; const rect = element.getBoundingClientRect(); const mostlyVisible = rect.width >= minimum && rect.height >= 12 && rect.right > 96 && rect.left < window.innerWidth - 96 && rect.bottom > 0 && rect.top < window.innerHeight; if (mostlyVisible) { return id; } } return null; }, minimumWidth); } async function findVisibleAllocationIdForResize(page: Page, selector: string) { return page.locator(selector).evaluateAll((elements) => { const candidates: Array<{ id: string; score: number }> = []; let fallbackId: string | null = null; for (const element of elements) { if (!(element instanceof HTMLElement)) continue; const id = element.dataset.allocationId; if (!id) continue; const rect = element.getBoundingClientRect(); const isVisible = rect.width >= 24 && rect.height >= 10 && rect.right > 48 && rect.left < window.innerWidth - 48 && rect.bottom > 0 && rect.top < window.innerHeight; if (!isVisible) { continue; } fallbackId ??= id; const isPreferred = rect.width >= 36 && rect.width <= 220 && rect.left >= 48 && rect.right <= window.innerWidth - 48; if (!isPreferred) { continue; } const centerX = rect.left + rect.width / 2; const widthPenalty = Math.abs(rect.width - 96); const centerPenalty = Math.abs(centerX - window.innerWidth / 2) / 4; candidates.push({ id, score: widthPenalty + centerPenalty }); } candidates.sort((a, b) => a.score - b.score); return candidates[0]?.id ?? fallbackId; }); } async function measureProjectBarDragGap(page: Page, locatorString: string) { const bar = page.locator(locatorString); await bar.scrollIntoViewIfNeeded(); const box = await bar.boundingBox(); expect(box).not.toBeNull(); if (!box) { throw new Error("Expected a visible project bar for drag measurement"); } const anchorOffsetX = Math.min(Math.max(box.width * 0.35, 16), box.width - 16); const pointerY = box.y + box.height / 2; const startX = box.x + anchorOffsetX; const dragDistance = 132; const gaps: number[] = []; const lefts: number[] = []; await page.mouse.move(startX, pointerY); await page.mouse.down(); for (let step = 1; step <= 8; step += 1) { const targetX = startX + (dragDistance * step) / 8; await page.mouse.move(targetX, pointerY, { steps: 1 }); await page.waitForTimeout(16); const currentBox = await bar.boundingBox(); if (!currentBox) continue; lefts.push(currentBox.x); gaps.push(Math.abs(currentBox.x - (targetX - anchorOffsetX))); } await page.mouse.up(); return { maxGap: Math.max(...gaps), movedDistance: Math.max(...lefts) - box.x, }; } async function measureAllocationDragGap(page: Page, locatorString: string) { const bar = page.locator(locatorString); await bar.scrollIntoViewIfNeeded(); const box = await bar.boundingBox(); expect(box).not.toBeNull(); if (!box) { throw new Error("Expected a visible allocation bar for drag measurement"); } const anchorOffsetX = Math.min(Math.max(box.width / 2, 18), box.width - 18); const pointerY = box.y + box.height / 2; const startX = box.x + anchorOffsetX; const dragDistance = 108; const gaps: number[] = []; const lefts: number[] = []; await page.mouse.move(startX, pointerY); await page.mouse.down(); for (let step = 1; step <= 8; step += 1) { const targetX = startX + (dragDistance * step) / 8; await page.mouse.move(targetX, pointerY, { steps: 1 }); await page.waitForTimeout(16); const currentBox = await bar.boundingBox(); if (!currentBox) continue; lefts.push(currentBox.x); gaps.push(Math.abs(currentBox.x - (targetX - anchorOffsetX))); } await page.mouse.up(); return { maxGap: Math.max(...gaps), movedDistance: Math.max(...lefts) - box.x, }; } async function measureAllocationResizeGap(page: Page, locatorString: string) { const bar = page.locator(locatorString); await bar.scrollIntoViewIfNeeded(); const box = await bar.boundingBox(); expect(box).not.toBeNull(); if (!box) { throw new Error("Expected a visible allocation bar for resize measurement"); } const pointerY = box.y + box.height / 2; const startX = box.x + box.width - 3; const resizeDistance = 96; const widths: number[] = []; const rightEdges: number[] = []; await page.mouse.move(startX, pointerY); await page.mouse.down(); for (let step = 1; step <= 8; step += 1) { const targetX = startX + (resizeDistance * step) / 8; await page.mouse.move(targetX, pointerY, { steps: 1 }); await page.waitForTimeout(16); const currentBox = await bar.boundingBox(); if (!currentBox) continue; widths.push(currentBox.width); rightEdges.push(currentBox.x + currentBox.width); } await page.mouse.up(); return { widthGain: Math.max(...widths) - box.width, rightEdgeGain: Math.max(...rightEdges) - (box.x + box.width), }; } async function measureAllocationResizeStartGap(page: Page, locatorString: string) { const bar = page.locator(locatorString); await bar.scrollIntoViewIfNeeded(); const box = await bar.boundingBox(); expect(box).not.toBeNull(); if (!box) { throw new Error("Expected a visible allocation bar for resize-start measurement"); } const pointerY = box.y + box.height / 2; const startX = box.x + 3; const resizeDistance = 72; const widths: number[] = []; const leftEdges: number[] = []; await page.mouse.move(startX, pointerY); await page.mouse.down(); for (let step = 1; step <= 8; step += 1) { const targetX = startX - (resizeDistance * step) / 8; await page.mouse.move(targetX, pointerY, { steps: 1 }); await page.waitForTimeout(16); const currentBox = await bar.boundingBox(); if (!currentBox) continue; widths.push(currentBox.width); leftEdges.push(currentBox.x); } await page.mouse.up(); return { widthGain: Math.max(...widths) - box.width, leftEdgeGain: box.x - Math.min(...leftEdges), }; } test.describe("Timeline", () => { test.describe.configure({ mode: "serial" }); test.beforeEach(async ({ page }) => { await page.addInitScript(() => { localStorage.setItem("capakraken_theme", JSON.stringify({ mode: "dark" })); }); await signInAsAdmin(page); await page.goto("/timeline"); }); test("loads and displays the timeline", async ({ page }) => { await expect(page.locator("text=Resource view")).toBeVisible(); await expect(page.locator("text=Project view")).toBeVisible(); await expect(page.getByRole("button", { name: /All Clients|Clients:/ })).toBeVisible(); await expect(page.getByRole("button", { name: /All Chapters|Chapters:/ })).toBeVisible(); await expect(page.getByRole("button", { name: /All people|People:/ })).toBeVisible(); // Timeline canvas should be visible await expect(page.locator("div.app-surface.relative.flex-1.overflow-auto")).toBeVisible(); }); test("can switch between resource and project view", async ({ page }) => { await page.click("text=Project view"); await expect( page.locator("text=0 projects").or(page.locator("text=/\\d+ projects/")), ).toBeVisible(); await page.click("text=Resource view"); await expect(page.locator("text=/\\d+ resources/")).toBeVisible(); }); test("can navigate forward and back", async ({ page }) => { const todayBtn = page.locator("button", { hasText: "Today" }); await expect(todayBtn).toBeVisible(); await page.locator("button", { hasText: "›" }).click(); await page.locator("button", { hasText: "‹" }).click(); await todayBtn.click(); }); test("keeps timeline data populated after navigating from allocations", async ({ page }) => { await page.goto("/allocations"); await expect( page.locator("h1").filter({ hasText: /Allocations|Planning/i }), ).toBeVisible({ timeout: 10000 }); await page.locator('nav a >> text="Timeline"').first().click(); await expect(page).toHaveURL(/\/timeline/); await expect( page.locator(".app-toolbar").getByText(/[1-9]\d* resources · [1-9]\d* allocations/), ).toBeVisible({ timeout: 10000 }); await expect(page.getByTestId("timeline-resource-row-canvas").first()).toBeVisible({ timeout: 10000, }); await expect(page.locator("text=No allocations in this time range")).not.toBeVisible(); }); test("filter panel opens and closes", async ({ page }) => { await page.locator("button", { hasText: "Filter" }).click(); await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible(); await expect(page.getByPlaceholder("Search projects…")).toBeVisible(); await page.keyboard.press("Escape"); await expect(page.getByRole("heading", { name: "Filters" })).not.toBeVisible(); }); test("shows placeholder bars for unassigned allocations", async ({ page }) => { // Filter to show placeholders (enabled by default) // The timeline should have at least one dashed placeholder bar from seed data await page.waitForSelector(".overflow-auto", { state: "visible" }); // Check that the timeline loaded (resource rows or empty state visible) await expect( page.locator(".app-toolbar").getByText(/\d+ resources · \d+ allocations/), ).toBeVisible(); }); test("clicking a placeholder opens the fill placeholder modal", async ({ page }) => { // Wait for timeline to load await page.waitForSelector(".overflow-auto"); await page.waitForTimeout(1000); // let tRPC queries settle // Try to find and click a placeholder bar (dashed border style) const placeholderBar = page.locator("[style*='dashed']").first(); if ((await placeholderBar.count()) > 0) { await placeholderBar.click(); await expect( page.locator("text=Fill Placeholder").or(page.locator("text=Assign Resource")), ).toBeVisible(); await page.keyboard.press("Escape"); } }); test("resource and project views keep tooltips opaque in dark mode and support right click", async ({ page, }) => { await page.waitForSelector(".overflow-auto", { state: "visible" }); await page.waitForTimeout(1000); const heatmapTooltip = page .locator("div.fixed.pointer-events-none.rounded-xl.border.border-gray-800") .first(); const allocationPopoverField = page.getByText("Hours / day"); const resourceHoverTarget = page.getByTestId("timeline-resource-row-canvas").first(); const resourceHoverBox = await resourceHoverTarget.boundingBox(); expect(resourceHoverBox).not.toBeNull(); if (!resourceHoverBox) { throw new Error("Expected a resource timeline row canvas to be available"); } await page.mouse.move(resourceHoverBox.x + 120, resourceHoverBox.y + 20); await expect(heatmapTooltip).toBeVisible(); await expect .poll(async () => { return heatmapTooltip.evaluate((element) => getComputedStyle(element).backgroundColor); }) .toBe("rgba(3, 7, 18, 0.96)"); const resourceAllocation = page .locator("[data-timeline-entry-type='allocation'][data-allocation-id]") .first(); await resourceAllocation.click({ button: "right" }); await expect(allocationPopoverField).toBeVisible(); await page.mouse.click(40, 40); await page.getByText("Project view").click(); await expect(page.getByText(/projects/)).toBeVisible(); await page.waitForTimeout(500); const projectHoverTarget = page.getByTestId("timeline-project-resource-row-canvas").first(); const projectHoverBox = await projectHoverTarget.boundingBox(); const projectAllocation = page.locator("div[style*='top: 2px'][style*='bottom: 2px']").nth(1); const projectAllocationBox = await projectAllocation.boundingBox(); expect(projectHoverBox).not.toBeNull(); expect(projectAllocationBox).not.toBeNull(); if (!projectHoverBox) { throw new Error("Expected a project timeline row canvas to be available"); } if (!projectAllocationBox) { throw new Error("Expected a project allocation block to be available"); } await page.mouse.move(projectAllocationBox.x + (projectAllocationBox.width / 2), projectHoverBox.y + 20); await expect(heatmapTooltip).toBeVisible(); await expect .poll(async () => { return heatmapTooltip.evaluate((element) => getComputedStyle(element).backgroundColor); }) .toBe("rgba(3, 7, 18, 0.96)"); await projectAllocation.click({ button: "right" }); await expect(allocationPopoverField).toBeVisible(); }); test("resource view right click resolves allocation popovers instead of getting stuck on loading", async ({ page, }) => { await page.waitForSelector(".overflow-auto", { state: "visible" }); await expect( page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(), ).toBeVisible(); const allocation = page .locator("[data-timeline-entry-type='allocation'][data-allocation-id]") .first(); await allocation.click({ button: "right" }); await expect(page.getByText("Loading...")).not.toBeVisible({ timeout: 2000 }); await expect(page.getByText("Hours / day")).toBeVisible(); await expect(page.getByRole("button", { name: "Open Project Panel →" })).toBeVisible(); }); test("right clicking a project header strip opens the project panel", async ({ page }) => { await page.getByText("Project view").click(); await expect(page.getByText(/projects/)).toBeVisible(); const projectBar = page .locator("[data-timeline-entry-type='project-bar'][data-timeline-project-id]") .first(); await expect(projectBar).toBeVisible(); await projectBar.click({ button: "right" }); await expect(page.getByText("Project Details")).toBeVisible(); await expect(page.getByRole("heading", { level: 2 })).toBeVisible(); await page.keyboard.press("Escape"); await expect(page.getByText("Project Details")).not.toBeVisible(); }); test("shows resolved holiday overlays in the resource timeline and exposes the holiday name in the tooltip", async ({ page, }) => { await page.goto("/timeline?startDate=2026-04-01&days=14&eids=bruce.banner", { waitUntil: "domcontentloaded", }); const row = page.locator('[data-testid="timeline-resource-row-canvas"][data-resource-eid="bruce.banner"]').first(); await expect(row).toBeVisible(); const holidayBlock = row.locator( '[data-testid="timeline-vacation-block"][data-vacation-type="PUBLIC_HOLIDAY"][data-vacation-note="Karfreitag"]', ).first(); await expect(holidayBlock).toBeVisible(); const rowBox = await row.boundingBox(); const holidayBox = await holidayBlock.boundingBox(); expect(rowBox).not.toBeNull(); expect(holidayBox).not.toBeNull(); if (!rowBox || !holidayBox) { throw new Error("Expected timeline row and holiday block bounding boxes to be available"); } await row.hover({ position: { x: holidayBox.x - rowBox.x + holidayBox.width / 2, y: holidayBox.y - rowBox.y + Math.min(holidayBox.height / 2, rowBox.height - 4), }, }); const holidayTooltip = page .locator("div.fixed.pointer-events-none.rounded-xl.border.border-amber-700\\/50") .or(page.locator("div.fixed.pointer-events-none.rounded-xl").filter({ hasText: "Karfreitag" })) .first(); await expect(holidayTooltip).toBeVisible(); await expect(holidayTooltip).toContainText("Karfreitag"); await expect(holidayTooltip).toContainText("3 April 2026"); }); test("allocation context popover stays interactive after pointer moves across the timeline canvas", async ({ page, }) => { await expect( page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(), ).toBeVisible(); const allocationId = await findVisibleAllocationIdForResize( page, "[data-timeline-entry-type='allocation'][data-allocation-id]", ); expect(allocationId).toBeTruthy(); const allocation = page.locator( `[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`, ); await allocation.click({ button: "right" }); const cancelButton = page.getByRole("button", { name: "Cancel" }).last(); await expect(cancelButton).toBeVisible(); const resourceRow = page.getByTestId("timeline-resource-row-canvas").first(); const resourceRowBox = await resourceRow.boundingBox(); expect(resourceRowBox).not.toBeNull(); if (!resourceRowBox) { throw new Error("Expected a resource timeline row canvas to be available"); } await page.mouse.move(resourceRowBox.x + 160, resourceRowBox.y + 20); await expect(cancelButton).toBeVisible(); await cancelButton.click(); await expect(cancelButton).not.toBeVisible(); }); test("timeline overlays react predictably to viewport changes", async ({ page }) => { const scrollContainer = page.locator("div.app-surface.relative.z-0.flex-1.overflow-auto"); await expect(scrollContainer).toBeVisible(); const resourceLabel = page.locator("[data-resource-hover-id]").first(); await resourceLabel.hover(); const hoverCard = page.locator("[data-resource-hover-card='true']"); await expect(hoverCard).toBeVisible(); const initialTop = await hoverCard.evaluate((element) => element.getBoundingClientRect().top); await scrollContainer.evaluate((element) => { element.scrollTop = Math.min(220, element.scrollHeight); element.dispatchEvent(new Event("scroll", { bubbles: true })); }); await expect(hoverCard).toBeVisible(); await expect .poll(async () => hoverCard.evaluate((element) => element.getBoundingClientRect().top)) .not.toBe(initialTop); await page.mouse.move(8, 8); await expect(hoverCard).not.toBeVisible(); const allocationId = await findVisibleAllocationIdForResize( page, "[data-timeline-entry-type='allocation'][data-allocation-id]", ); expect(allocationId).toBeTruthy(); const allocation = page.locator( `[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`, ); await allocation.click({ button: "right" }); const cancelButton = page.getByRole("button", { name: "Cancel" }).last(); await expect(cancelButton).toBeVisible(); await scrollContainer.evaluate((element) => { element.scrollTop = Math.min(element.scrollTop + 160, element.scrollHeight); element.dispatchEvent(new Event("scroll", { bubbles: true })); }); await expect(cancelButton).not.toBeVisible(); }); test("project bars stay attached to the pointer during fast drag", async ({ page }) => { await page.getByText("Project view").click(); await expect(page.getByText(/projects/)).toBeVisible(); await expect(page.locator("[data-timeline-entry-type='project-bar']").first()).toBeVisible(); const projectId = await findVisibleTimelineEntryId( page, "[data-timeline-entry-type='project-bar'][data-timeline-project-id]", 24, ); expect(projectId).toBeTruthy(); const result = await measureProjectBarDragGap( page, `[data-timeline-entry-type='project-bar'][data-timeline-project-id='${projectId}']`, ); expect(result.movedDistance).toBeGreaterThan(72); expect(result.maxGap).toBeLessThan(24); }); test("resource allocation bars stay attached to the pointer during drag", async ({ page }) => { await page.goto("/timeline?startDate=2026-04-01&days=31", { waitUntil: "domcontentloaded", }); await expect( page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(), ).toBeVisible(); const allocationId = await findVisibleAllocationIdForResize( page, "[data-timeline-entry-type='allocation'][data-allocation-id]", ); expect(allocationId).toBeTruthy(); const result = await measureAllocationDragGap( page, `[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`, ); expect(result.movedDistance).toBeGreaterThan(56); expect(result.maxGap).toBeLessThan(24); }); test("allocation resize shows a live preview before mouseup", async ({ page, }) => { await page.goto("/timeline?startDate=2026-04-01&days=31", { waitUntil: "domcontentloaded", }); await expect( page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(), ).toBeVisible(); const allocationId = await findVisibleAllocationIdForResize( page, "[data-timeline-entry-type='allocation'][data-allocation-id]", ); expect(allocationId).toBeTruthy(); const result = await measureAllocationResizeGap( page, `[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`, ); expect(result.widthGain).toBeGreaterThan(64); expect(result.rightEdgeGain).toBeGreaterThan(48); }); test("allocation start resize shows a live preview before mouseup", async ({ page, }) => { await page.goto("/timeline?startDate=2026-04-01&days=31", { waitUntil: "domcontentloaded", }); await expect( page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(), ).toBeVisible(); const allocationId = await findVisibleAllocationIdForResize( page, "[data-timeline-entry-type='allocation'][data-allocation-id]", ); expect(allocationId).toBeTruthy(); const result = await measureAllocationResizeStartGap( page, `[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`, ); expect(result.widthGain).toBeGreaterThan(48); expect(result.leftEdgeGain).toBeGreaterThan(36); }); test("resource timeline supports resizing, moving, carving, and recreating segmented allocations with persisted dates", async ({ page, }) => { const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`; const scenario = createTimelineSegmentScenario(suffix); const scenarioSnapshot = readScenarioSnapshot( scenario.projectId, scenario.resourceId, scenario.resourceEid, ); try { expect(scenarioSnapshot.resource?.eid).toBe(scenario.resourceEid); expect(scenarioSnapshot.project?.id).toBe(scenario.projectId); expect(scenarioSnapshot.assignments).toEqual([ { id: scenario.assignmentId, startDate: "2026-04-06", endDate: "2026-04-17", status: "ACTIVE", }, ]); await page.goto(`/timeline?startDate=2026-04-01&days=30&eids=${scenario.resourceEid}`, { waitUntil: "domcontentloaded", }); const row = page .locator( `[data-testid="timeline-resource-row-canvas"][data-resource-eid="${scenario.resourceEid}"]`, ) .first(); await expect(row).toBeVisible(); const renderedSegments = await listRenderedAllocationSegments(row, scenario.assignmentId); console.log( `[timeline-e2e] rendered segments ${JSON.stringify({ resourceEid: scenario.resourceEid, assignmentId: scenario.assignmentId, renderedSegments, })}`, ); const baseSegment = row.locator( `[data-allocation-id="${scenario.assignmentId}"][data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-10"]`, ); await expect(baseSegment).toBeVisible(); await expect(baseSegment.locator('[data-allocation-handle="start"]')).toBeVisible(); await expect(baseSegment.locator('[data-allocation-handle="end"]')).toBeVisible(); const baseSegmentBox = await readBoundingBox(baseSegment); const dayWidth = Math.round((baseSegmentBox.width + 4) / 5); expect(dayWidth).toBeGreaterThan(8); await dragLocatorBy(page, baseSegment.locator('[data-allocation-handle="start"]'), dayWidth); await releaseMouse(page); await waitForScenarioAssignments(scenario.projectId, [ { startDate: "2026-04-07", endDate: "2026-04-10" }, { startDate: "2026-04-11", endDate: "2026-04-17" }, ]); const resizedSegment = row.locator( `[data-allocation-id="${scenario.assignmentId}"][data-allocation-segment-start="2026-04-07"][data-allocation-segment-end="2026-04-10"]`, ); await expect(resizedSegment).toBeVisible(); await dragLocatorBy(page, resizedSegment.locator('[data-allocation-interaction="body"]'), -dayWidth); await releaseMouse(page); await waitForScenarioAssignments(scenario.projectId, [ { startDate: "2026-04-06", endDate: "2026-04-09" }, { startDate: "2026-04-11", endDate: "2026-04-17" }, ]); const movedSegment = row.locator( `[data-allocation-id="${scenario.assignmentId}"][data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-09"]`, ); await expect(movedSegment).toBeVisible(); await openAllocationContextMenuAtOffset(page, movedSegment, dayWidth * 1.5); const cancelButton = page.getByRole("button", { name: "Cancel" }).last(); await expect(cancelButton).toBeVisible(); await cancelButton.click(); await expect(cancelButton).not.toBeVisible(); await dragLocatorBy(page, movedSegment.locator('[data-allocation-handle="end"]'), dayWidth); await releaseMouse(page); await waitForScenarioAssignments(scenario.projectId, [ { startDate: "2026-04-06", endDate: "2026-04-10" }, { startDate: "2026-04-11", endDate: "2026-04-17" }, ]); const restoredSegment = row.locator( `[data-allocation-id="${scenario.assignmentId}"][data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-10"]`, ); await expect(restoredSegment).toBeVisible(); await openAllocationContextMenuAtOffset(page, restoredSegment, dayWidth * 2.5); const carveDateInputs = page.locator('input[placeholder="dd/mm/yyyy"]'); await expect(carveDateInputs.nth(2)).toHaveValue("08/04/2026"); await expect(carveDateInputs.nth(3)).toHaveValue("08/04/2026"); await page.getByRole("button", { name: "Remove Selected Range" }).click(); await waitForScenarioAssignments(scenario.projectId, [ { startDate: "2026-04-06", endDate: "2026-04-07" }, { startDate: "2026-04-09", endDate: "2026-04-10" }, { startDate: "2026-04-11", endDate: "2026-04-17" }, ]); const leftSplit = row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first(); const rightSplit = row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first(); const nextWeekSegment = row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first(); await expect(leftSplit).toBeVisible(); await expect(rightSplit).toBeVisible(); await expect(nextWeekSegment).toBeVisible(); const leftSplitBox = await readBoundingBox(leftSplit); const rightSplitBox = await readBoundingBox(rightSplit); const rowBox = await readBoundingBox(row); const gapCenterX = (leftSplitBox.x + leftSplitBox.width + rightSplitBox.x) / 2; const gapCenterY = rowBox.y + Math.min(rowBox.height / 2, rowBox.height - 6); await page.mouse.move(gapCenterX, gapCenterY); await page.mouse.down(); await page.mouse.up(); await expect(page.getByText("Assign to Project")).toBeVisible(); await selectProjectFromCombobox(page, scenario.projectShortCode, scenario.projectName); await page.getByRole("button", { name: "Assign" }).click(); await waitForScenarioAssignments(scenario.projectId, [ { startDate: "2026-04-06", endDate: "2026-04-07" }, { startDate: "2026-04-08", endDate: "2026-04-08" }, { startDate: "2026-04-09", endDate: "2026-04-10" }, { startDate: "2026-04-11", endDate: "2026-04-17" }, ]); await expect( row.locator('[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-08"]').first(), ).toBeVisible(); await page.reload({ waitUntil: "domcontentloaded" }); await expect(row).toBeVisible(); await expect( row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first(), ).toBeVisible(); await expect( row.locator('[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-08"]').first(), ).toBeVisible(); await expect( row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first(), ).toBeVisible(); await expect( row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first(), ).toBeVisible(); } finally { cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId); } }); test("resource timeline persists multi-day carve and reverse recreation after reload", async ({ page, }) => { test.setTimeout(60_000); const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`; const scenario = createTimelineSegmentScenario(suffix); try { await page.goto(`/timeline?startDate=2026-04-01&days=30&eids=${scenario.resourceEid}`, { waitUntil: "domcontentloaded", }); const row = page .locator( `[data-testid="timeline-resource-row-canvas"][data-resource-eid="${scenario.resourceEid}"]`, ) .first(); await expect(row).toBeVisible(); const baseSegment = row.locator( `[data-allocation-id="${scenario.assignmentId}"][data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-10"]`, ); await expect(baseSegment).toBeVisible(); const baseSegmentBox = await readBoundingBox(baseSegment); const dayWidth = Math.round((baseSegmentBox.width + 4) / 5); expect(dayWidth).toBeGreaterThan(8); await openAllocationContextMenuAtOffset(page, baseSegment, dayWidth * 2.5); const carveDateInputs = page.locator('input[placeholder="dd/mm/yyyy"]'); await carveDateInputs.nth(2).fill("08/04/2026"); await carveDateInputs.nth(3).fill("09/04/2026"); await page.getByRole("button", { name: "Remove Selected Range" }).click(); await waitForScenarioAssignments(scenario.projectId, [ { startDate: "2026-04-06", endDate: "2026-04-07" }, { startDate: "2026-04-10", endDate: "2026-04-17" }, ]); await page.reload({ waitUntil: "domcontentloaded" }); await expect(row).toBeVisible(); const leftSplit = row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first(); const fridayBridge = row.locator('[data-allocation-segment-start="2026-04-10"][data-allocation-segment-end="2026-04-10"]').first(); const mondaySegment = row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first(); await expect(leftSplit).toBeVisible(); await expect(fridayBridge).toBeVisible(); await expect(mondaySegment).toBeVisible(); const fridayBridgeBox = await readBoundingBox(fridayBridge); await dragRowSelection( page, row, fridayBridgeBox.x - dayWidth / 2, fridayBridgeBox.x - dayWidth * 1.5, ); await expect(page.getByText("Assign to Project")).toBeVisible(); await selectProjectFromCombobox(page, scenario.projectShortCode, scenario.projectName); await page.getByRole("button", { name: "Assign" }).click(); await waitForScenarioAssignments(scenario.projectId, [ { startDate: "2026-04-06", endDate: "2026-04-07" }, { startDate: "2026-04-08", endDate: "2026-04-09" }, { startDate: "2026-04-10", endDate: "2026-04-17" }, ]); await page.reload({ waitUntil: "domcontentloaded" }); await expect(row).toBeVisible(); await expect( row.locator('[data-allocation-segment-start="2026-04-08"][data-allocation-segment-end="2026-04-09"]').first(), ).toBeVisible(); await expect( row.locator('[data-allocation-segment-start="2026-04-10"][data-allocation-segment-end="2026-04-10"]').first(), ).toBeVisible(); await expect( row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first(), ).toBeVisible(); } finally { cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId); } }); test("resource timeline narrow split fragments keep both handles and monday context dates", async ({ page, }) => { test.setTimeout(60_000); const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`; const scenario = createTimelineSegmentScenario(suffix); try { await page.goto(`/timeline?startDate=2026-04-01&days=30&eids=${scenario.resourceEid}`, { waitUntil: "domcontentloaded", }); const row = page .locator( `[data-testid="timeline-resource-row-canvas"][data-resource-eid="${scenario.resourceEid}"]`, ) .first(); await expect(row).toBeVisible(); const baseSegment = row.locator( `[data-allocation-id="${scenario.assignmentId}"][data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-10"]`, ); await expect(baseSegment).toBeVisible(); const baseSegmentBox = await readBoundingBox(baseSegment); const dayWidth = Math.round((baseSegmentBox.width + 4) / 5); expect(dayWidth).toBeGreaterThan(8); await openAllocationContextMenuAtOffset(page, baseSegment, dayWidth * 2.5); const carveDateInputs = page.locator('input[placeholder="dd/mm/yyyy"]'); await expect(carveDateInputs.nth(2)).toHaveValue("08/04/2026"); await expect(carveDateInputs.nth(3)).toHaveValue("08/04/2026"); await page.getByRole("button", { name: "Remove Selected Range" }).click(); await waitForScenarioAssignments(scenario.projectId, [ { startDate: "2026-04-06", endDate: "2026-04-07" }, { startDate: "2026-04-09", endDate: "2026-04-17" }, ]); const leftSplit = row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first(); const rightSplit = row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first(); const mondaySegment = row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first(); await expect(leftSplit).toBeVisible(); await expect(rightSplit).toBeVisible(); await expect(mondaySegment).toBeVisible(); await expect(leftSplit.locator('[data-allocation-handle="start"]')).toBeVisible(); await expect(leftSplit.locator('[data-allocation-handle="end"]')).toBeVisible(); await expect(rightSplit.locator('[data-allocation-handle="start"]')).toBeVisible(); await expect(rightSplit.locator('[data-allocation-handle="end"]')).toBeVisible(); await dragLocatorBy(page, leftSplit.locator('[data-allocation-handle="start"]'), dayWidth); await releaseMouse(page); await waitForScenarioAssignments(scenario.projectId, [ { startDate: "2026-04-07", endDate: "2026-04-07" }, { startDate: "2026-04-09", endDate: "2026-04-17" }, ]); const resizedRightSplit = row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first(); await dragLocatorBy(page, resizedRightSplit.locator('[data-allocation-handle="end"]'), -dayWidth); await releaseMouse(page); await waitForScenarioAssignments(scenario.projectId, [ { startDate: "2026-04-07", endDate: "2026-04-07" }, { startDate: "2026-04-09", endDate: "2026-04-09" }, { startDate: "2026-04-11", endDate: "2026-04-17" }, ]); await page.reload({ waitUntil: "domcontentloaded" }); await expect(row).toBeVisible(); const mondaySegmentAfterReload = row.locator( '[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]', ).first(); await expect(mondaySegmentAfterReload).toBeVisible(); const mondayCarveDateInputs = page.locator('input[placeholder="dd/mm/yyyy"]'); const mondayCancelButton = page.getByRole("button", { name: "Cancel" }).last(); await openAllocationContextMenuAtOffset(page, mondaySegmentAfterReload, dayWidth * 0.5); await expect(page.getByText("Loading...")).not.toBeVisible(); await expect(mondayCancelButton).toBeVisible(); await expect(mondayCarveDateInputs.nth(2)).toHaveValue("13/04/2026"); await expect(mondayCarveDateInputs.nth(3)).toHaveValue("13/04/2026"); await mondayCancelButton.click(); await expect(mondayCancelButton).not.toBeVisible(); await openAllocationContextMenuAtOffset(page, mondaySegmentAfterReload, dayWidth * 4.5); await expect(page.getByText("Loading...")).not.toBeVisible(); await expect(mondayCancelButton).toBeVisible(); await expect(mondayCarveDateInputs.nth(2)).toHaveValue("17/04/2026"); await expect(mondayCarveDateInputs.nth(3)).toHaveValue("17/04/2026"); await mondayCancelButton.click(); await expect(mondayCancelButton).not.toBeVisible(); } finally { cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId); } }); test("resource timeline can delete a two-day weekday fragment without disturbing adjacent pieces", async ({ page, }) => { test.setTimeout(60_000); const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`; const scenario = createTimelineSegmentScenario(suffix); try { await page.goto(`/timeline?startDate=2026-04-01&days=30&eids=${scenario.resourceEid}`, { waitUntil: "domcontentloaded", }); const row = page .locator( `[data-testid="timeline-resource-row-canvas"][data-resource-eid="${scenario.resourceEid}"]`, ) .first(); await expect(row).toBeVisible(); const baseSegment = row.locator( `[data-allocation-id="${scenario.assignmentId}"][data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-10"]`, ); await expect(baseSegment).toBeVisible(); const baseSegmentBox = await readBoundingBox(baseSegment); const dayWidth = Math.round((baseSegmentBox.width + 4) / 5); expect(dayWidth).toBeGreaterThan(8); await openAllocationContextMenuAtOffset(page, baseSegment, dayWidth * 2.5); const carveDateInputs = page.locator('input[placeholder="dd/mm/yyyy"]'); await carveDateInputs.nth(2).fill("08/04/2026"); await carveDateInputs.nth(3).fill("08/04/2026"); await page.getByRole("button", { name: "Remove Selected Range" }).click(); await waitForScenarioAssignments(scenario.projectId, [ { startDate: "2026-04-06", endDate: "2026-04-07" }, { startDate: "2026-04-09", endDate: "2026-04-17" }, ]); const leftSplit = row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first(); const rightSplit = row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first(); const mondaySegment = row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first(); await expect(leftSplit).toBeVisible(); await expect(rightSplit).toBeVisible(); await expect(mondaySegment).toBeVisible(); await openAllocationContextMenuAtOffset(page, leftSplit, dayWidth * 0.5); await carveDateInputs.nth(2).fill("06/04/2026"); await carveDateInputs.nth(3).fill("07/04/2026"); await page.getByRole("button", { name: "Remove Selected Range" }).click(); await waitForScenarioAssignments(scenario.projectId, [ { startDate: "2026-04-09", endDate: "2026-04-17" }, ]); await expect( row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first(), ).toHaveCount(0); await expect(rightSplit).toBeVisible(); await expect(mondaySegment).toBeVisible(); await page.reload({ waitUntil: "domcontentloaded" }); await expect(row).toBeVisible(); await expect( row.locator('[data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-07"]').first(), ).toHaveCount(0); await expect( row.locator('[data-allocation-segment-start="2026-04-09"][data-allocation-segment-end="2026-04-10"]').first(), ).toBeVisible(); await expect( row.locator('[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]').first(), ).toBeVisible(); } finally { cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId); } }); test("project view demand bars expose hover details", async ({ page }) => { await page.getByText("Project view").click(); await expect(page.getByText(/projects/)).toBeVisible(); await expect(page.locator("[data-timeline-entry-type='demand']").first()).toBeVisible(); const demandId = await findVisibleTimelineEntryId( page, "[data-timeline-entry-type='demand'][data-allocation-id]", 72, ); expect(demandId).toBeTruthy(); const demandBar = page.locator( `[data-timeline-entry-type='demand'][data-allocation-id='${demandId}']`, ); await demandBar.hover(); const demandTooltip = page.getByTestId("timeline-demand-tooltip"); await expect(demandTooltip).toBeVisible(); await expect(demandTooltip).toContainText("Requested"); await expect(demandTooltip).toContainText("Open"); await expect(demandTooltip).toContainText("Click for details and actions"); }); });