import { mkdir } from "node:fs/promises"; import { resolve as resolvePath } from "node:path"; import { chromium, expect } from "@playwright/test"; import type { Locator, Page, Request, Response } from "@playwright/test"; import { createId } from "@paralleldrive/cuid2"; import { stringify as stringifySuperJSON } from "superjson"; import { describe, test } from "vitest"; import { LOGICAL_GRID_CELL_SIZE, LOGICAL_GRID_GAP, MIN_ACCESSIBLE_CANVAS_SCALE, } from "../apps/nextjs/src/components/board/layout/constants"; import * as sqliteSchema from "../packages/db/schema/sqlite"; import { createHomarrContainer } from "./shared/create-homarr-container"; import { createSqliteDbFileAsync } from "./shared/e2e-db"; import type { SqliteDatabase } from "./shared/e2e-db"; import { seedAdminUserAsync } from "./shared/seed-admin-user"; const adminCredentials = { username: "admin", password: "Comp(exP4sswOrd", }; const editRuntimeMarker = "homarr-edit-grid-v1"; const gridStackPayloadMarker = "--gs-column-width"; const logicalCellSize = LOGICAL_GRID_CELL_SIZE; const logicalCellPitch = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP; const minimumCanvasScale = MIN_ACCESSIBLE_CANVAS_SCALE; describe("Board grid", () => { test("supports fixed, nested, rail, pointer, keyboard and reconciliation workflows", async () => { const { db, localMountPath } = await createSqliteDbFileAsync(); const { userId } = await seedAdminUserAsync(db, adminCredentials); const fixture = await seedBoardGridAsync(db, userId); const screenshotDirectory = await prepareScreenshotDirectoryAsync(); const homarrContainer = await createHomarrContainer({ environment: { AUTH_PROVIDERS: "credentials", }, mounts: { "/appdata": localMountPath, }, }).start(); let browser: Awaited> | undefined; let runtimeResources: ReturnType | undefined; try { const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`; browser = await chromium.launch(); const context = await browser.newContext({ viewport: { width: 1920, height: 1200, }, }); const page = await context.newPage(); await installLayoutShiftObserverAsync(page); runtimeResources = trackRuntimeResources(page); const pageErrors: Error[] = []; page.on("pageerror", (error) => { pageErrors.push(error); console.error(error.stack); }); const noScriptContext = await browser.newContext({ javaScriptEnabled: false, viewport: { width: 1920, height: 1200, }, }); const noScriptPage = await noScriptContext.newPage(); await noScriptPage.goto(`${baseUrl}/boards/${fixture.boardName}`); const serverRenderedCanvas = noScriptPage.getByTestId("board-canvas"); await expect(serverRenderedCanvas).toHaveAttribute("data-board-hydrated", "false"); const initialCanvasHeight = Number(await serverRenderedCanvas.getAttribute("data-canvas-initial-height")); expect(initialCanvasHeight).toBeGreaterThan(0); await expect(serverRenderedCanvas.locator(`[data-grid-item-id="${fixture.firstItemId}"]`)).toHaveCount(1); await noScriptContext.close(); await signInAsync(page, baseUrl, `/boards/${fixture.boardName}`); const canvas = page.getByTestId("board-canvas"); await expect(canvas).toHaveAttribute("data-board-hydrated", "true"); await expect(page.locator(`[data-grid-runtime="${editRuntimeMarker}"]`)).toHaveCount(0); const mainSection = page.locator(`[data-section-id="${fixture.sectionId}"]`); const rail = page.getByRole("complementary", { name: "Left dashboard rail" }); const railSection = page.locator(`[data-grid-item-id="${fixture.railSectionId}"]`); const railRootSection = rail.locator("[data-section-kind='empty']"); const railItem = page.locator(`[data-grid-item-id="${fixture.railItemId}"]`); const dynamicSection = page.locator(`[data-grid-item-id="${fixture.dynamicSectionId}"]`); const nestedItem = page.locator(`[data-grid-item-id="${fixture.nestedItemId}"]`); const belowItem = page.locator(`[data-grid-item-id="${fixture.belowItemId}"]`); await expect(mainSection).toHaveAttribute("data-section-kind", "empty"); await expect(mainSection).toHaveAttribute("data-rail-placement", "main"); await expect(rail).toBeVisible(); await expect(rail).toHaveAttribute("data-board-gutter", "left"); await expect(railRootSection).toHaveAttribute("data-rail-placement", "left"); await expect(railSection).toHaveAttribute("data-grid-w", "2"); await expect(railItem).toHaveAttribute("data-grid-x", "0"); await expect(dynamicSection).toHaveAttribute("data-grid-w", "2"); await expect(dynamicSection).toHaveAttribute("data-grid-h", "2"); await expect(dynamicSection.getByText("Nested box", { exact: true })).toBeVisible(); await expect(nestedItem).toHaveAttribute("data-grid-x", "0"); await expect(belowItem).toHaveAttribute("data-grid-y", "2"); const firstItem = page.locator(`[data-grid-item-id="${fixture.firstItemId}"]`); const secondItem = page.locator(`[data-grid-item-id="${fixture.secondItemId}"]`); await expect(firstItem).toHaveAttribute("data-grid-x", "0"); await expect(firstItem).toHaveAttribute("data-grid-y", "0"); await expect(firstItem).toHaveAttribute("data-grid-w", "1"); await expect(firstItem).toHaveAttribute("data-grid-h", "1"); await expect(secondItem).toHaveAttribute("data-grid-x", "1"); await expect(secondItem).toHaveAttribute("data-grid-y", "0"); const logicalTile = firstItem.locator("[data-grid-item-content]"); await expect(logicalTile).toHaveCount(1); await expectFixedLogicalTileAsync(logicalTile); await expect(canvas).toHaveAttribute("data-canvas-overflow", "false"); const normalZoomTileBox = await expectBoundingBoxAsync(logicalTile); const normalCanvasScale = await readCanvasScaleAsync(canvas); const secondItemBox = await expectBoundingBoxAsync(secondItem); expect(secondItemBox.x - (normalZoomTileBox.x + normalZoomTileBox.width)).toBeCloseTo( (logicalCellPitch - logicalCellSize) * normalCanvasScale, 1, ); const editToggle = page.getByTestId("board-edit-mode-toggle"); await runtimeResources.waitForQuietAsync(); expect(runtimeResources.urlsFor("read-only", "editor")).toEqual([]); expect(runtimeResources.urlsFor("read-only", "gridstack")).toEqual([]); expect(await readLayoutShiftScoreAsync(page)).toBeLessThan(0.01); await resetLayoutShiftScoreAsync(page); await releaseIdleCallbacksAsync(page); await expect(editToggle).toHaveAttribute("data-board-editor-preload-state", "ready", { timeout: 15_000, }); await runtimeResources.waitForQuietAsync(); expect(runtimeResources.urlsFor("read-only", "editor").length).toBeGreaterThan(0); expect(runtimeResources.urlsFor("read-only", "gridstack").length).toBeGreaterThan(0); expect(await readLayoutShiftScoreAsync(page)).toBeLessThan(0.01); // CSS zoom exercises Chromium's browser-zoom layout path without relying // on desktop browser chrome. At 200% the scale floor must preserve // magnification and expose horizontal scrolling instead of shrinking the // board by the inverse zoom factor. await setDocumentZoomAsync(page, 2); await expect(canvas).toHaveAttribute("data-canvas-scale", String(minimumCanvasScale)); await expect(canvas).toHaveAttribute("data-canvas-overflow", "true"); await expectFixedLogicalTileAsync(logicalTile); await expectUniformVisualScaleAsync(logicalTile); const fittedClockFontSize = await firstItem.locator(".clock-time-text").evaluate((element) => { return Number.parseFloat(getComputedStyle(element).fontSize); }); expect(fittedClockFontSize * minimumCanvasScale).toBeGreaterThanOrEqual(25); const zoomedTileBox = await expectBoundingBoxAsync(logicalTile); expect(zoomedTileBox.width).toBeGreaterThan(normalZoomTileBox.width * 1.25); await expectHorizontalOverflowAsync(canvas); const overflowRailY = (await expectBoundingBoxAsync(rail)).y; await page.evaluate(() => window.scrollTo({ top: 300, behavior: "auto" })); await expect.poll(async () => (await expectBoundingBoxAsync(rail)).y).toBeGreaterThanOrEqual(0); expect((await expectBoundingBoxAsync(rail)).y).toBeLessThanOrEqual(overflowRailY); await page.evaluate(() => window.scrollTo({ top: 0, behavior: "auto" })); await setDocumentZoomAsync(page, 1); await expect(canvas).toHaveAttribute("data-canvas-overflow", "false"); const collapseDynamic = page.getByRole("button", { name: "Collapse: Nested box", exact: true, }); await collapseDynamic.click(); await expect(dynamicSection).toHaveAttribute("data-grid-h", "1"); await expect(belowItem).toHaveAttribute("data-grid-y", "1"); await expect(nestedItem).toHaveCount(0); await page .getByRole("button", { name: "Expand: Nested box", exact: true, }) .click(); await expect(dynamicSection).toHaveAttribute("data-grid-h", "2"); await expect(belowItem).toHaveAttribute("data-grid-y", "2"); await expect(nestedItem).toHaveCount(1); await captureBoardScreenshotAsync(canvas, screenshotDirectory, "board-grid-read-only.png"); const viewMainSectionBox = await expectBoundingBoxAsync(mainSection); const viewFirstItemBox = await expectBoundingBoxAsync(logicalTile); runtimeResources.enterEditMode(); await editToggle.click(); await expect(page.locator(`[data-grid-runtime="${editRuntimeMarker}"]`)).toHaveCount(4); await expect(page.getByTestId("board-grid-editor-loading")).toHaveCount(0); await expect(page.getByTestId("board-canvas-row-count-button")).toHaveCount(0); await expectBoundingBoxToMatchAsync(mainSection, viewMainSectionBox); await expectBoundingBoxToMatchAsync(logicalTile, viewFirstItemBox); await runtimeResources.waitForQuietAsync(); expect(runtimeResources.urlsFor("edit", "editor")).toEqual([]); expect(runtimeResources.urlsFor("edit", "gridstack")).toEqual([]); runtimeResources.stop(); await expect(mainSection).toHaveAttribute("role", "region"); await expect(mainSection).toHaveAttribute("data-grid-editable", "true"); await expect(firstItem.locator(`[data-grid-id="${fixture.firstItemId}"]`)).toHaveAttribute("role", "group"); await expectTargetSizeAsync(getEditorEntry(firstItem)); await expectTargetSizeAsync(firstItem.locator(':scope > [data-testid="board-grid-resize-handle"]')); await expectLogicalTargetSizeAsync(firstItem.locator(':scope > [data-testid="board-grid-resize-handle"]'), 44); await expect(firstItem.locator("[data-board-grid-inert-content]")).toHaveAttribute("inert", ""); await page.getByRole("button", { name: "Add board content" }).click(); await expect(page.getByRole("menuitem", { name: "New section", exact: true })).toBeVisible(); await expect(page.getByRole("menuitem", { name: "New category", exact: true })).toHaveCount(0); await page.getByRole("menuitem", { name: "New item", exact: true }).click(); const itemSelectDialog = page.getByRole("dialog", { name: "Choose item to add" }); await expect(itemSelectDialog).toBeVisible(); await page.keyboard.press("Escape"); await expect(itemSelectDialog).toHaveCount(0); const railBoxBeforeMainDrag = await expectBoundingBoxAsync(rail); const canvasScale = await readCanvasScaleAsync(canvas); await dragLocatorByAsync(page, getEditorEntry(firstItem), logicalCellPitch * canvasScale, 0); await expect(firstItem).toHaveAttribute("data-grid-x", "1"); await expect(secondItem).toHaveAttribute("data-grid-x", "0"); await dragLocatorByAsync(page, getEditorEntry(firstItem), logicalCellPitch * canvasScale * 3, 0); await expect(firstItem).toHaveAttribute("data-grid-x", "4"); expect(gridLayoutsOverlap(await readGridLayoutAsync(firstItem), await readGridLayoutAsync(secondItem))).toBe( false, ); const railBoxAfterMainDrag = await expectBoundingBoxAsync(rail); expect(railBoxAfterMainDrag.x).toBeCloseTo(railBoxBeforeMainDrag.x, 1); expect(railBoxAfterMainDrag.width).toBeCloseTo(railBoxBeforeMainDrag.width, 1); await expect(railItem.locator("xpath=ancestor::aside[1]")).toHaveAttribute("aria-label", "Left dashboard rail"); const secondItemContent = secondItem.locator("[data-grid-item-content]"); await expect(secondItemContent).toHaveCount(1); await secondItemContent.evaluate((element) => { element.setAttribute("data-e2e-portal-instance", "second-item-content"); }); const railEditor = rail.locator("[data-grid-section-id]").first(); const railGridBox = await expectBoundingBoxAsync(railEditor); await dragLocatorToAsync( page, getEditorEntry(secondItem), railGridBox.x + logicalCellPitch * canvasScale * 1.5, railGridBox.y + logicalCellSize * canvasScale * 0.5, ); await expect(secondItem.locator("xpath=ancestor::aside[1]")).toHaveAttribute("data-board-gutter", "left"); await expect( secondItem.locator('[data-grid-item-content][data-e2e-portal-instance="second-item-content"]'), ).toBeVisible(); await expect(getEditorEntry(secondItem)).toHaveCount(1); expect(pageErrors).toEqual([]); await moveEntryWithModalAsync(page, secondItem, /Dashboard canvas, destination \d+/); await expect(secondItem.locator("xpath=ancestor::*[@data-grid-section-id][1]")).toHaveAttribute( "data-grid-section-id", fixture.sectionId, ); await expect(getEditorEntry(secondItem)).toBeFocused(); await moveEntryWithModalAsync(page, secondItem, /Left dashboard rail, destination \d+/); await expect(secondItem.locator("xpath=ancestor::aside[1]")).toHaveAttribute("data-board-gutter", "left"); await expect(getEditorEntry(secondItem)).toBeFocused(); await expect( secondItem.locator('[data-grid-item-content][data-e2e-portal-instance="second-item-content"]'), ).toBeVisible(); const railKeyboardEntry = getEditorEntry(railItem); await railKeyboardEntry.focus(); await railKeyboardEntry.press("Enter"); await expect(railKeyboardEntry).toHaveAttribute("data-keyboard-editing", "true"); await railKeyboardEntry.press("Shift+ArrowRight"); await expect(railItem).toHaveAttribute("data-grid-w", "2"); await railKeyboardEntry.press("Shift+ArrowLeft"); await expect(railItem).toHaveAttribute("data-grid-w", "1"); await railKeyboardEntry.press("ArrowRight"); await expect(railItem).toHaveAttribute("data-grid-x", "1"); await railKeyboardEntry.press("Escape"); await expect(railKeyboardEntry).toHaveAttribute("data-keyboard-editing", "false"); const dynamicResizeHandle = dynamicSection.locator(':scope > [data-testid="board-grid-resize-handle"]'); await expectLogicalTargetSizeAsync(dynamicResizeHandle, 44); await dragLocatorByAsync(page, dynamicResizeHandle, 0, -logicalCellPitch * canvasScale); await expect(dynamicSection).toHaveAttribute("data-grid-h", "1"); const minimumSectionBox = await expectBoundingBoxAsync(dynamicSection); const minimumHandleBox = await expectBoundingBoxAsync(dynamicResizeHandle); const resizeStartX = minimumHandleBox.x + minimumHandleBox.width / 2; const resizeStartY = minimumHandleBox.y + minimumHandleBox.height / 2; await page.mouse.move(resizeStartX, resizeStartY); await page.mouse.down(); await page.mouse.move(resizeStartX, resizeStartY - logicalCellPitch * canvasScale * 2, { steps: 12 }); const constrainedSectionBox = await expectBoundingBoxAsync(dynamicSection); expect(Math.abs(constrainedSectionBox.height - minimumSectionBox.height)).toBeLessThan(1); await page.mouse.up(); await expect(dynamicSection).toHaveAttribute("data-grid-h", "1"); await dragLocatorByAsync(page, dynamicResizeHandle, 0, logicalCellPitch * canvasScale * 2); await expect(dynamicSection).toHaveAttribute("data-grid-h", "3"); await expect(belowItem).toHaveAttribute("data-grid-y", "3"); expect(gridLayoutsOverlap(await readGridLayoutAsync(dynamicSection), await readGridLayoutAsync(belowItem))).toBe( false, ); // The main canvas grows with its content instead of requiring a manual // row limit, and items can occupy empty rows below their original position. for (let targetRow = 1; targetRow <= 8; targetRow += 1) { await firstItem.evaluate((element) => element.scrollIntoView({ block: "center", behavior: "instant" })); await dragLocatorByAsync(page, getEditorEntry(firstItem), 0, logicalCellPitch * canvasScale + 2); await expect(firstItem).toHaveAttribute("data-grid-y", String(targetRow)); } await expect(firstItem).toHaveAttribute("data-grid-y", "8"); expect(gridLayoutsOverlap(await readGridLayoutAsync(firstItem), await readGridLayoutAsync(belowItem))).toBe( false, ); const stickyRailY = (await expectBoundingBoxAsync(rail)).y; await page.evaluate(() => window.scrollTo({ top: 500, behavior: "auto" })); const scrolledRailY = (await expectBoundingBoxAsync(rail)).y; expect(scrolledRailY).toBeGreaterThanOrEqual(0); expect(scrolledRailY).toBeLessThanOrEqual(stickyRailY + 0.5); await page.evaluate(() => window.scrollTo({ top: 0, behavior: "auto" })); await setDocumentZoomAsync(page, 1.25); await expectFixedLogicalTileAsync(logicalTile); await expectUniformVisualScaleAsync(logicalTile); await expectTargetSizeAsync(getEditorEntry(firstItem)); await expectTargetSizeAsync(firstItem.locator(':scope > [data-testid="board-grid-resize-handle"]')); await setDocumentZoomAsync(page, 1); await captureBoardScreenshotAsync(canvas, screenshotDirectory, "board-grid-edit-mode.png"); const dynamicLayoutBeforeDelete = await readGridLayoutAsync(dynamicSection); await dynamicSection.getByRole("button", { name: "Settings for Nested box" }).click(); await page .getByRole("menuitem", { name: "Remove section", exact: true, }) .click(); await page .getByRole("dialog", { name: "Remove section", }) .getByRole("button", { name: "Confirm", exact: true, }) .click(); await expect(dynamicSection).toHaveCount(0); await expect(nestedItem).toHaveCount(1); await expect(nestedItem.locator("xpath=ancestor::*[@data-grid-section-id][1]")).toHaveAttribute( "data-grid-section-id", fixture.sectionId, ); await expect(nestedItem).toHaveAttribute("data-grid-x", String(dynamicLayoutBeforeDelete.x)); await expect(nestedItem).toHaveAttribute("data-grid-y", String(dynamicLayoutBeforeDelete.y)); await page.getByTestId("board-edit-mode-toggle").click(); await expect(page.locator(`[data-grid-runtime="${editRuntimeMarker}"]`)).toHaveCount(0); await page.reload(); const reloadedCanvas = page.locator('[data-testid="board-canvas"][data-board-hydrated="true"]'); const reloadedFirstItem = reloadedCanvas.locator(`[data-grid-item-id="${fixture.firstItemId}"]`); const reloadedSecondItem = reloadedCanvas.locator(`[data-grid-item-id="${fixture.secondItemId}"]`); const reloadedRailItem = reloadedCanvas.locator(`[data-grid-item-id="${fixture.railItemId}"]`); const reloadedDynamicSection = reloadedCanvas.locator(`[data-grid-item-id="${fixture.dynamicSectionId}"]`); const reloadedNestedItem = reloadedCanvas.locator(`[data-grid-item-id="${fixture.nestedItemId}"]`); await expect(reloadedCanvas).toHaveCount(1); await expect(reloadedFirstItem).toHaveAttribute("data-grid-x", "4"); await expect(reloadedFirstItem).toHaveAttribute("data-grid-y", "8"); await expect(reloadedRailItem).toHaveAttribute("data-grid-x", "1"); await expect(reloadedRailItem).toHaveAttribute("data-grid-w", "1"); await expect(reloadedDynamicSection).toHaveCount(0); await expect(reloadedNestedItem).toHaveCount(1); await expect(reloadedNestedItem.locator("xpath=ancestor::*[@data-grid-section-id][1]")).toHaveAttribute( "data-grid-section-id", fixture.sectionId, ); await expect(reloadedSecondItem.locator("xpath=ancestor::aside[1]")).toHaveAttribute("data-board-gutter", "left"); const persistedFirstLayout = await readGridLayoutAsync(reloadedFirstItem); const persistedSecondLayout = await readGridLayoutAsync(reloadedSecondItem); expect(gridLayoutsOverlap(persistedFirstLayout, persistedSecondLayout)).toBe(false); await page.goto(`${baseUrl}/boards/${fixture.boardName}/settings?tab=layout`); const gutterPreview = page.getByLabel("Dashboard layout preview").filter({ visible: true }); await expect(gutterPreview).toBeVisible(); const rightGutterSwitch = page.getByRole("switch", { name: "Right sidebar" }); await rightGutterSwitch.check(); const rightGutterSlider = page.getByRole("slider", { name: "Right sidebar width" }); await expect(rightGutterSlider).toHaveAttribute("aria-valuenow", "2"); await rightGutterSlider.press("ArrowRight"); await expect(rightGutterSlider).toHaveAttribute("aria-valuenow", "3"); await captureBoardScreenshotAsync(page.locator("main"), screenshotDirectory, "board-layout-gutter-settings.png"); await Promise.all([ page.waitForResponse( (response) => response.url().includes("/api/trpc/board.saveLayouts") && response.status() === 200, ), rightGutterSwitch.locator("xpath=ancestor::form[1]").getByRole("button", { name: "Save changes" }).click(), ]); await page.goto(`${baseUrl}/boards/${fixture.boardName}`); const boardWithBothGutters = page.locator('[data-testid="board-canvas"][data-board-hydrated="true"]'); await expect(boardWithBothGutters).toHaveCount(1); await expect(page.getByRole("complementary", { name: "Left dashboard rail" })).toBeVisible(); const emptyRightGutter = page.getByRole("complementary", { name: "Right dashboard rail" }); await expect(emptyRightGutter).toHaveCount(1); expect((await expectBoundingBoxAsync(emptyRightGutter)).width).toBeGreaterThan(0); expect(pageErrors).toEqual([]); } finally { runtimeResources?.stop(); await browser?.close(); await homarrContainer.stop(); } }, 180_000); }); const seedBoardGridAsync = async (db: SqliteDatabase, creatorId: string) => { const boardId = createId(); const boardName = "grid-e2e"; const layoutId = createId(); const sectionId = createId(); const railSectionId = createId(); const dynamicSectionId = createId(); const firstItemId = createId(); const secondItemId = createId(); const belowItemId = createId(); const nestedItemId = createId(); const railItemId = createId(); await db.insert(sqliteSchema.boards).values({ id: boardId, name: boardName, creatorId, isPublic: true, }); await db.insert(sqliteSchema.layouts).values({ id: layoutId, name: "Desktop", boardId, columnCount: 8, breakpoint: 0, }); await db.insert(sqliteSchema.sections).values([ { id: sectionId, boardId, kind: "empty", xOffset: 0, yOffset: 0, }, { id: railSectionId, boardId, kind: "category", name: "Pinned rail", xOffset: 0, yOffset: 0, options: stringifySuperJSON({ showLabel: true, collapsible: true, showOpenAll: false, railPlacement: "left", columnCount: 2, }), }, { id: dynamicSectionId, boardId, kind: "dynamic", options: stringifySuperJSON({ title: "Nested box", customCssClasses: [], borderColor: "", showLabel: true, collapsible: true, showOpenAll: false, }), }, ]); await db.insert(sqliteSchema.sectionCollapseStates).values({ userId: creatorId, sectionId: railSectionId, // Legacy categories stored true when the category was open. collapsed: true, }); await db.insert(sqliteSchema.sectionLayouts).values({ sectionId: dynamicSectionId, layoutId, parentSectionId: sectionId, xOffset: 2, yOffset: 0, width: 2, height: 2, }); await db.insert(sqliteSchema.items).values([ { id: firstItemId, boardId, kind: "clock", options: stringifySuperJSON({ is24HourFormat: true }), }, { id: secondItemId, boardId, kind: "clock", options: stringifySuperJSON({ is24HourFormat: true }), }, { id: belowItemId, boardId, kind: "clock", options: stringifySuperJSON({ is24HourFormat: true }), }, { id: nestedItemId, boardId, kind: "clock", options: stringifySuperJSON({ is24HourFormat: true }), }, { id: railItemId, boardId, kind: "clock", options: stringifySuperJSON({ is24HourFormat: true }), }, ]); await db.insert(sqliteSchema.itemLayouts).values([ { itemId: firstItemId, sectionId, layoutId, xOffset: 0, yOffset: 0, width: 1, height: 1, }, { itemId: secondItemId, sectionId, layoutId, xOffset: 1, yOffset: 0, width: 1, height: 1, }, { itemId: belowItemId, sectionId, layoutId, xOffset: 2, yOffset: 2, width: 2, height: 1, }, { itemId: nestedItemId, sectionId: dynamicSectionId, layoutId, xOffset: 0, yOffset: 0, width: 1, height: 1, }, { itemId: railItemId, sectionId: railSectionId, layoutId, xOffset: 0, yOffset: 0, width: 1, height: 1, }, ]); return { boardName, sectionId, railSectionId, dynamicSectionId, firstItemId, secondItemId, belowItemId, nestedItemId, railItemId, }; }; const signInAsync = async (page: Page, baseUrl: string, callbackPath: string) => { const loginUrl = new URL("/auth/login", baseUrl); loginUrl.searchParams.set("callbackUrl", callbackPath); await page.goto(loginUrl.href); await page.getByLabel("Username").fill(adminCredentials.username); await page.getByRole("textbox", { name: "Password", exact: true }).fill(adminCredentials.password); await page.locator("button[type='submit']").click(); await page.waitForURL(`${baseUrl}${callbackPath}`, { timeout: 15_000 }); }; const prepareScreenshotDirectoryAsync = async () => { const configuredDirectory = process.env.HOMARR_E2E_SCREENSHOT_DIR?.trim(); if (!configuredDirectory) return undefined; const directory = resolvePath(configuredDirectory); await mkdir(directory, { recursive: true }); return directory; }; const captureBoardScreenshotAsync = async ( canvas: Locator, screenshotDirectory: string | undefined, fileName: string, ) => { if (!screenshotDirectory) return; await canvas.screenshot({ animations: "disabled", caret: "hide", path: resolvePath(screenshotDirectory, fileName), }); }; type RuntimeResourcePhase = "read-only" | "edit"; type RuntimePayload = "editor" | "gridstack"; interface RuntimeResource { phase: RuntimeResourcePhase; url: string; hasEditorPayload: boolean; hasGridStackPayload: boolean; } const trackRuntimeResources = (page: Page) => { const resources: RuntimeResource[] = []; const pendingResponses = new Set>(); const inFlightRequests = new Set(); const requestPhases = new WeakMap(); const bodyErrors: Error[] = []; let phase: RuntimeResourcePhase = "read-only"; let lastActivityAt = Date.now(); let stopped = false; const handleRequest = (request: Request) => { requestPhases.set(request, phase); if (!isRuntimeResourceType(request.resourceType())) return; inFlightRequests.add(request); lastActivityAt = Date.now(); }; const handleRequestFailed = (request: Request) => { if (!inFlightRequests.delete(request)) return; lastActivityAt = Date.now(); }; const handleResponse = (response: Response) => { const resourceKind = getRuntimeResourceKind(response); if (!resourceKind) return; const request = response.request(); const responsePhase = requestPhases.get(request) ?? phase; inFlightRequests.delete(request); lastActivityAt = Date.now(); const task = readRuntimeResponseBodyAsync(page, response) .then((body) => { resources.push({ phase: responsePhase, url: response.url(), hasEditorPayload: body.includes(Buffer.from(editRuntimeMarker)), hasGridStackPayload: body.includes(Buffer.from(gridStackPayloadMarker)), }); }) .catch((error: unknown) => { bodyErrors.push(new Error(`Unable to inspect ${resourceKind} response ${response.url()}`, { cause: error })); }); pendingResponses.add(task); void task.finally(() => { pendingResponses.delete(task); lastActivityAt = Date.now(); }); }; page.on("request", handleRequest); page.on("requestfailed", handleRequestFailed); page.on("response", handleResponse); return { enterEditMode() { phase = "edit"; }, urlsFor(resourcePhase: RuntimeResourcePhase, payload: RuntimePayload) { const key = payload === "editor" ? "hasEditorPayload" : "hasGridStackPayload"; return [ ...new Set( resources .filter((resource) => resource.phase === resourcePhase && resource[key]) .map((resource) => resource.url), ), ]; }, async waitForQuietAsync({ quietMs = 300, timeoutMs = 15_000 } = {}) { const deadline = Date.now() + timeoutMs; while (true) { if (pendingResponses.size > 0) { await Promise.all(pendingResponses); } if (bodyErrors.length > 0) { throw new AggregateError(bodyErrors, "Unable to inspect one or more JavaScript or CSS responses"); } const quietFor = Date.now() - lastActivityAt; if (pendingResponses.size === 0 && inFlightRequests.size === 0 && quietFor >= quietMs) return; if (Date.now() >= deadline) { throw new Error( `JavaScript and CSS requests did not become quiet within ${timeoutMs}ms ` + `(${inFlightRequests.size} requests and ${pendingResponses.size} response bodies pending)`, ); } await new Promise((resolve) => setTimeout(resolve, Math.min(50, Math.max(1, quietMs - quietFor)))); } }, stop() { if (stopped) return; stopped = true; page.off("request", handleRequest); page.off("requestfailed", handleRequestFailed); page.off("response", handleResponse); }, }; }; const readRuntimeResponseBodyAsync = async (page: Page, response: Response) => { try { return await response.body(); } catch (originalError) { try { const fallbackResponse = await page.request.get(response.url(), { failOnStatusCode: false, }); if (!fallbackResponse.ok()) { throw new Error(`Fallback request returned HTTP ${fallbackResponse.status()}`, { cause: originalError, }); } return await fallbackResponse.body(); } catch (fallbackError) { throw new Error( `Unable to read or refetch runtime response ${response.url()}. Original read error: ${String(originalError)}`, { cause: fallbackError }, ); } } }; const isRuntimeResourceType = (resourceType: string) => resourceType === "script" || resourceType === "stylesheet"; const getRuntimeResourceKind = (response: Response): "script" | "stylesheet" | null => { const resourceType = response.request().resourceType(); if (resourceType === "script" || resourceType === "stylesheet") return resourceType; const contentType = response.headers()["content-type"]?.toLowerCase() ?? ""; if (contentType.includes("javascript") || contentType.includes("ecmascript")) return "script"; if (contentType.includes("text/css")) return "stylesheet"; return null; }; const expectBoundingBoxAsync = async (locator: Locator) => { const box = await locator.boundingBox(); expect(box).not.toBeNull(); if (box === null) { throw new Error("Expected locator to have a bounding box"); } return box; }; const expectBoundingBoxToMatchAsync = async ( locator: Locator, expected: { x: number; y: number; width: number; height: number }, ) => { const actual = await expectBoundingBoxAsync(locator); expect(actual.x).toBeCloseTo(expected.x, 0); expect(actual.y).toBeCloseTo(expected.y, 0); expect(actual.width).toBeCloseTo(expected.width, 0); expect(actual.height).toBeCloseTo(expected.height, 0); }; const installLayoutShiftObserverAsync = async (page: Page) => { await page.addInitScript(() => { const target = window as typeof window & { homarrLayoutShiftScore?: number; homarrReleaseIdleCallbacks?: () => void; }; const idleCallbacks = new Map(); let nextIdleCallbackId = 1; target.homarrLayoutShiftScore = 0; target.requestIdleCallback = (callback) => { const callbackId = nextIdleCallbackId++; idleCallbacks.set(callbackId, callback); return callbackId; }; target.cancelIdleCallback = (callbackId) => { idleCallbacks.delete(callbackId); }; target.homarrReleaseIdleCallbacks = () => { const callbacks = [...idleCallbacks.values()]; idleCallbacks.clear(); for (const callback of callbacks) { callback({ didTimeout: false, timeRemaining: () => 50, }); } }; new PerformanceObserver((list) => { for (const entry of list.getEntries()) { const shift = entry as PerformanceEntry & { hadRecentInput: boolean; value: number }; if (!shift.hadRecentInput) { target.homarrLayoutShiftScore = (target.homarrLayoutShiftScore ?? 0) + shift.value; } } }).observe({ type: "layout-shift", buffered: true }); }); }; const releaseIdleCallbacksAsync = async (page: Page) => { await page.evaluate(() => { const release = ( window as typeof window & { homarrReleaseIdleCallbacks?: () => void; } ).homarrReleaseIdleCallbacks; if (!release) { throw new Error("Idle callback release hook was not installed"); } release(); }); }; const readLayoutShiftScoreAsync = async (page: Page) => await page.evaluate( () => (window as typeof window & { homarrLayoutShiftScore?: number }).homarrLayoutShiftScore ?? 0, ); const resetLayoutShiftScoreAsync = async (page: Page) => { await page.evaluate(() => { (window as typeof window & { homarrLayoutShiftScore?: number }).homarrLayoutShiftScore = 0; }); }; const dragLocatorByAsync = async (page: Page, locator: Locator, deltaX: number, deltaY: number) => { const box = await expectBoundingBoxAsync(locator); await dragLocatorToAsync(page, locator, box.x + box.width / 2 + deltaX, box.y + box.height / 2 + deltaY); }; const dragLocatorToAsync = async (page: Page, locator: Locator, targetX: number, targetY: number) => { const box = await expectBoundingBoxAsync(locator); 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(targetX, targetY, { steps: 12 }); await page.mouse.up(); }; const readCanvasScaleAsync = async (canvas: Locator) => { const scale = Number(await canvas.getAttribute("data-canvas-scale")); expect(Number.isFinite(scale)).toBe(true); expect(scale).toBeGreaterThan(0); return scale; }; const setDocumentZoomAsync = async (page: Page, zoom: number) => { await page.evaluate((nextZoom) => { document.documentElement.style.zoom = String(nextZoom); }, zoom); await page.evaluate( () => new Promise((resolve) => { requestAnimationFrame(() => requestAnimationFrame(() => resolve())); }), ); }; const moveEntryWithModalAsync = async (page: Page, entry: Locator, targetName: RegExp) => { await entry.getByRole("button", { name: /^Settings for / }).click(); await page.getByRole("menuitem", { name: "Move / resize item", exact: true }).click(); const dialog = page.getByRole("dialog", { name: "Move / resize item" }); await dialog.getByLabel("Section or rail").click(); await page.getByRole("option", { name: targetName }).click(); await dialog.getByRole("button", { name: "Save changes", exact: true }).click(); }; const getEditorEntry = (entry: Locator) => entry.locator("[data-editor-grid-entry]").first(); const expectFixedLogicalTileAsync = async (locator: Locator) => { await expect(locator).toHaveJSProperty("offsetWidth", logicalCellSize); await expect(locator).toHaveJSProperty("offsetHeight", logicalCellSize); }; const expectUniformVisualScaleAsync = async (locator: Locator) => { const scale = await locator.evaluate((element) => { const rect = element.getBoundingClientRect(); const htmlElement = element as HTMLElement; return { horizontal: rect.width / htmlElement.offsetWidth, vertical: rect.height / htmlElement.offsetHeight, }; }); expect(scale.horizontal).toBeCloseTo(scale.vertical, 3); }; const expectHorizontalOverflowAsync = async (locator: Locator) => { const dimensions = await locator.evaluate((element) => { const htmlElement = element as HTMLElement; return { clientWidth: htmlElement.clientWidth, scrollWidth: htmlElement.scrollWidth, }; }); expect(dimensions.scrollWidth).toBeGreaterThan(dimensions.clientWidth); }; const expectTargetSizeAsync = async (locator: Locator) => { const box = await expectBoundingBoxAsync(locator); expect(box.width).toBeGreaterThanOrEqual(24); expect(box.height).toBeGreaterThanOrEqual(24); }; const expectLogicalTargetSizeAsync = async (locator: Locator, minimumSize: number) => { const dimensions = await locator.evaluate((element) => ({ width: (element as HTMLElement).offsetWidth, height: (element as HTMLElement).offsetHeight, })); expect(dimensions.width).toBeGreaterThanOrEqual(minimumSize); expect(dimensions.height).toBeGreaterThanOrEqual(minimumSize); }; const readGridLayoutAsync = async (locator: Locator) => { const [x, y, width, height] = await Promise.all([ locator.getAttribute("data-grid-x"), locator.getAttribute("data-grid-y"), locator.getAttribute("data-grid-w"), locator.getAttribute("data-grid-h"), ]); expect(x).not.toBeNull(); expect(y).not.toBeNull(); expect(width).not.toBeNull(); expect(height).not.toBeNull(); return { x: Number(x), y: Number(y), width: Number(width), height: Number(height), }; }; const gridLayoutsOverlap = ( first: { x: number; y: number; width: number; height: number }, second: { x: number; y: number; width: number; height: number }, ) => first.x < second.x + second.width && first.x + first.width > second.x && first.y < second.y + second.height && first.y + first.height > second.y;