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 } 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 gridRuntimeMarker = "homarr-dnd-kit-v1"; const dndKitPayloadMarker = "dnd-kit-description"; const logicalCellSize = LOGICAL_GRID_CELL_SIZE; const logicalCellPitch = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP; 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.locator('[data-testid="board-canvas"][data-board-hydrated="true"]'); await expect(canvas).toHaveAttribute("data-board-hydrated", "true"); const normalCanvasScale = await readCanvasScaleAsync(canvas); await expect(page.locator(`[data-grid-runtime="${gridRuntimeMarker}"]`)).toHaveCount(0); const mainSection = page.locator(`[data-section-id="${fixture.sectionId}"]`); const rail = page.getByRole("complementary", { name: "Left dashboard rail" }); const railRootSection = rail.locator(`[data-section-id="${fixture.railSectionId}"][data-section-kind="empty"]`); const railItem = page.locator(`[data-grid-item-id="${fixture.railItemId}"]`); const containerSection = page.locator(`[data-grid-item-id="${fixture.containerSectionId}"]`); 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(rail).toHaveCSS("overflow-x", "clip"); await expect(rail).toHaveCSS("overflow-y", "clip"); const initialRailBox = await expectBoundingBoxAsync(rail); const initialRailRootBox = await expectBoundingBoxAsync(railRootSection); expect(initialRailRootBox.width).toBeCloseTo(initialRailBox.width, 1); expect(initialRailRootBox.height).toBeGreaterThanOrEqual(initialRailBox.height - 1); await expect(railItem).toHaveAttribute("data-grid-x", "0"); await expect(containerSection).toHaveAttribute("data-grid-w", "2"); await expect(containerSection).toHaveAttribute("data-grid-h", "2"); const containerLabel = containerSection.locator("[data-board-container-label]"); await expect(containerLabel).toHaveText("Nested box"); await expect(containerLabel).toHaveCSS("border-top-left-radius", "4px"); const expandedContainerBox = await expectBoundingBoxAsync(containerSection); const expandedContainerLabelBox = await expectBoundingBoxAsync(containerLabel); expect(Math.abs(expandedContainerLabelBox.x - expandedContainerBox.x)).toBeLessThanOrEqual(3); expect(Math.abs(expandedContainerLabelBox.width - expandedContainerBox.width)).toBeLessThanOrEqual(3); const openContainerApps = containerSection.getByRole("button", { name: "Open all apps in Nested box in tabs", exact: true, }); const expandedOpenContainerAppsBox = await expectBoundingBoxAsync(openContainerApps); expect( expandedContainerLabelBox.x + expandedContainerLabelBox.width - expandedOpenContainerAppsBox.x - expandedOpenContainerAppsBox.width, ).toBeCloseTo(8 * normalCanvasScale, 1); await expect(nestedItem).toHaveAttribute("data-grid-x", "0"); await expect(belowItem).toHaveAttribute("data-grid-y", "2"); const containerLabelBox = await expectBoundingBoxAsync(containerLabel); const nestedItemBox = await expectBoundingBoxAsync(nestedItem.locator("[data-grid-item-content]")); expect(containerLabelBox.y + containerLabelBox.height).toBeLessThanOrEqual(nestedItemBox.y + 0.5); 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(logicalTile).toHaveCSS("overflow-x", "hidden"); await expect(logicalTile).toHaveCSS("overflow-y", "auto"); await page.addStyleTag({ content: ".e2e-widget-overflow { overflow: visible; }" }); await expect(secondItem.locator("[data-grid-item-content]")).toHaveCSS("overflow", "visible"); await expect(canvas).toHaveAttribute("data-canvas-overflow", "false"); await expectDocumentNotHorizontallyScrollableAsync(page); const nestedAppLink = nestedItem.locator('a[href="#board-app-opened"]'); await expect(nestedAppLink).toBeVisible(); await nestedAppLink.click(); await expect(page).toHaveURL(/#board-app-opened$/); await page.evaluate(() => history.replaceState(null, "", `${location.pathname}${location.search}`)); await firstItem.click({ button: "right" }); await expect(page.getByRole("menu")).toBeVisible(); await page.keyboard.press("Escape"); await nestedItem.click({ button: "right" }); await expect(page.getByRole("menu")).toHaveCount(0); const normalZoomTileBox = await expectBoundingBoxAsync(logicalTile); 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", "runtime-marker")).toEqual([]); expect(runtimeResources.urlsFor("read-only", "dnd-kit")).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", "runtime-marker").length).toBeGreaterThan(0); expect(runtimeResources.urlsFor("read-only", "dnd-kit").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. The board must keep fitting without adding // horizontal scrolling at any effective zoom. await setDocumentZoomAsync(page, 2); const zoomedCanvasScale = await readCanvasScaleAsync(canvas); expect(zoomedCanvasScale).toBeLessThan(normalCanvasScale); await expect(canvas).toHaveAttribute("data-canvas-overflow", "false"); await expectFixedLogicalTileAsync(logicalTile); await expectUniformVisualScaleAsync(logicalTile); await expect(canvas).toHaveCSS("overflow-x", "clip"); await expectDocumentNotHorizontallyScrollableAsync(page); 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 collapseContainer = page.getByRole("button", { name: "Collapse: Nested box", exact: true, }); await nestedItem.evaluate((element) => { element.setAttribute("data-e2e-preserved-through-collapse", "true"); }); await collapseContainer.click(); await expect(containerSection).toHaveAttribute("data-grid-h", "0.5"); await expect(belowItem).toHaveAttribute("data-grid-y", "0.5"); await expect(nestedItem).toBeHidden(); await expect(nestedItem).toHaveAttribute("data-e2e-preserved-through-collapse", "true"); await expect(containerSection.locator("[data-collapsed='true']")).toHaveAttribute("inert", ""); const collapsedControl = containerSection.getByRole("button", { name: "Expand: Nested box", exact: true }); await expect(collapsedControl).toBeVisible(); await expect(collapsedControl).toHaveAttribute("data-board-container-collapsed-control", "true"); const expectedCollapsedHeight = (logicalCellPitch * 0.5 - LOGICAL_GRID_GAP) * normalCanvasScale; await expect .poll(async () => (await expectBoundingBoxAsync(containerSection)).height) .toBeCloseTo(expectedCollapsedHeight, 1); const collapsedContainerBox = await expectBoundingBoxAsync(containerSection); const collapsedControlBox = await expectBoundingBoxAsync(collapsedControl); const collapsedOpenContainerAppsBox = await expectBoundingBoxAsync(openContainerApps); expect(Math.abs(collapsedControlBox.x - collapsedContainerBox.x)).toBeLessThanOrEqual(3); expect(Math.abs(collapsedControlBox.y - collapsedContainerBox.y)).toBeLessThanOrEqual(3); expect(Math.abs(collapsedControlBox.width - collapsedContainerBox.width)).toBeLessThanOrEqual(3); expect(Math.abs(collapsedControlBox.height - collapsedContainerBox.height)).toBeLessThanOrEqual(3); expect( collapsedControlBox.x + collapsedControlBox.width - collapsedOpenContainerAppsBox.x - collapsedOpenContainerAppsBox.width, ).toBeCloseTo(8 * normalCanvasScale, 1); await page .getByRole("button", { name: "Expand: Nested box", exact: true, }) .click(); await expect(containerSection).toHaveAttribute("data-grid-h", "2"); await expect(belowItem).toHaveAttribute("data-grid-y", "2"); await expect(nestedItem).toBeVisible(); await expect(nestedItem).toHaveAttribute("data-e2e-preserved-through-collapse", "true"); await captureBoardScreenshotAsync(canvas, screenshotDirectory, "board-grid-read-only.png"); const viewMainSectionBox = await expectBoundingBoxAsync(mainSection); const viewFirstItemBox = await expectBoundingBoxAsync(logicalTile); const viewScrollY = await page.evaluate(() => window.scrollY); runtimeResources.enterEditMode(); await editToggle.click(); await expect(page.locator(`[data-grid-runtime="${gridRuntimeMarker}"]`)).toHaveCount(3); await expect(page.getByTestId("board-grid-editor-loading")).toHaveCount(0); await expect(page.getByTestId("board-canvas-row-count-button")).toHaveCount(0); await expect(rail).toHaveAttribute("data-board-editing", "true"); await expect(rail).toHaveCSS("position", "sticky"); await expect(rail).toHaveCSS("transform", "none"); const railBox = await expectBoundingBoxAsync(rail); expect(railBox.height + railBox.y).toBeCloseTo(page.viewportSize()?.height ?? 0, 0); expect(await rail.evaluate((element) => getComputedStyle(element, "::before").content.replaceAll('"', ""))).toBe( "Left dashboard rail", ); const railGrid = rail.locator("[data-grid-section-id]").first(); const railSnapshot = await readEditorGridSnapshotAsync(railGrid); await rail.evaluate((element) => { const overflowFixture = document.createElement("div"); overflowFixture.dataset.e2eRailOverflowFixture = "true"; overflowFixture.style.position = "absolute"; overflowFixture.style.insetInlineStart = "100%"; overflowFixture.style.width = "500px"; overflowFixture.style.height = "1px"; overflowFixture.style.pointerEvents = "none"; element.appendChild(overflowFixture); }); await expect .poll(async () => await rail.evaluate((element) => element.scrollWidth - element.clientWidth)) .toBeGreaterThan(0); const railItemBox = await expectBoundingBoxAsync(railItem); const railDragStart = { x: railItemBox.x + railItemBox.width / 2, y: railItemBox.y + railItemBox.height / 2, }; await page.mouse.move(railDragStart.x, railDragStart.y); await page.mouse.down(); await page.mouse.move(railDragStart.x + 8, railDragStart.y); await expect(railItem).toHaveAttribute("data-dnd-drag-source", "true"); await page.mouse.move(railBox.x + railBox.width - 2, railDragStart.y, { steps: 12 }); await page.waitForTimeout(250); expect(await rail.evaluate((element) => element.scrollLeft)).toBe(0); expect((await expectBoundingBoxAsync(rail)).x).toBeCloseTo(railBox.x, 1); await page.keyboard.press("Escape"); await page.mouse.up(); await expect.poll(async () => await readEditorGridSnapshotAsync(railGrid)).toEqual(railSnapshot); await rail.locator('[data-e2e-rail-overflow-fixture="true"]').evaluate((element) => element.remove()); await rail.evaluate((element) => { const overflowFixture = document.createElement("div"); overflowFixture.dataset.e2eRailOverflowFixture = "true"; overflowFixture.style.position = "absolute"; overflowFixture.style.insetBlockStart = "100%"; overflowFixture.style.width = "1px"; overflowFixture.style.height = "500px"; overflowFixture.style.pointerEvents = "none"; element.appendChild(overflowFixture); }); await expect .poll(async () => await rail.evaluate((element) => element.scrollHeight - element.clientHeight)) .toBeGreaterThan(0); const fixedRailGridHeight = (await expectBoundingBoxAsync(railGrid)).height; const fixedRailHeight = (await expectBoundingBoxAsync(rail)).height; const verticalRailItemBox = await expectBoundingBoxAsync(railItem); await page.mouse.move( verticalRailItemBox.x + verticalRailItemBox.width / 2, verticalRailItemBox.y + verticalRailItemBox.height / 2, ); await page.mouse.down(); await page.mouse.move(verticalRailItemBox.x + verticalRailItemBox.width / 2 + 8, verticalRailItemBox.y); await expect(railItem).toHaveAttribute("data-dnd-drag-source", "true"); await page.mouse.move(railBox.x + railBox.width / 2, railBox.y + railBox.height - 2, { steps: 12 }); await page.waitForTimeout(250); expect(await rail.evaluate((element) => element.scrollTop)).toBe(0); expect((await expectBoundingBoxAsync(rail)).height).toBeCloseTo(fixedRailHeight, 1); expect((await expectBoundingBoxAsync(railGrid)).height).toBeCloseTo(fixedRailGridHeight, 1); await page.keyboard.press("Escape"); await page.mouse.up(); await expect.poll(async () => await readEditorGridSnapshotAsync(railGrid)).toEqual(railSnapshot); await rail.locator('[data-e2e-rail-overflow-fixture="true"]').evaluate((element) => element.remove()); await page.evaluate((scrollY) => window.scrollTo({ top: scrollY, behavior: "auto" }), viewScrollY); await expect(logicalTile).toHaveCSS("overflow-x", "hidden"); await expect(logicalTile).toHaveCSS("overflow-y", "auto"); await logicalTile.evaluate((element) => { const overflowFixture = document.createElement("div"); overflowFixture.dataset.e2eWidgetOverflowFixture = "true"; overflowFixture.style.width = "800px"; overflowFixture.style.minWidth = "800px"; overflowFixture.style.height = "800px"; overflowFixture.style.minHeight = "800px"; overflowFixture.style.flex = "0 0 800px"; element.appendChild(overflowFixture); }); await expect .poll(async () => await logicalTile.evaluate((element) => element.scrollWidth - element.clientWidth)) .toBeGreaterThan(0); await expect .poll(async () => await logicalTile.evaluate((element) => element.scrollHeight - element.clientHeight)) .toBeGreaterThan(0); await logicalTile.evaluate((element) => { element.scrollTop = 100; }); await expect.poll(async () => await logicalTile.evaluate((element) => element.scrollTop)).toBeGreaterThan(0); await expectDocumentNotHorizontallyScrollableAsync(page); await logicalTile.locator('[data-e2e-widget-overflow-fixture="true"]').evaluate((element) => element.remove()); await expectBoundingBoxToMatchAsync(mainSection, viewMainSectionBox); await expectBoundingBoxToMatchAsync(logicalTile, viewFirstItemBox); await runtimeResources.waitForQuietAsync(); expect(runtimeResources.urlsFor("edit", "runtime-marker")).toEqual([]); expect(runtimeResources.urlsFor("edit", "dnd-kit")).toEqual([]); runtimeResources.stop(); await expect(mainSection).toHaveAttribute("role", "region"); await expect(mainSection).toHaveAttribute("data-grid-editable", "true"); await expect(firstItem).toHaveClass(/\bboard-grid-entry\b/); const firstKeyboardEntry = firstItem.locator(`[data-grid-id="${fixture.firstItemId}"]`); await expect(firstKeyboardEntry).toHaveAttribute("role", "group"); await expect(firstKeyboardEntry).not.toHaveAttribute("aria-pressed"); await expect(firstKeyboardEntry).not.toHaveAttribute("aria-grabbed"); await expectTargetSizeAsync(firstItem); await expect(firstItem.getByTestId("board-grid-drag-affordance")).toHaveCount(0); const firstResizeHandles = firstItem.locator(":scope > [data-grid-resize-handle]"); await expect(firstResizeHandles).toHaveCount(8); expect( await firstResizeHandles.evaluateAll((handles) => handles.map((handle) => handle.getAttribute("data-grid-resize-handle")), ), ).toEqual(["n", "ne", "e", "se", "s", "sw", "w", "nw"]); expect( await firstResizeHandles.evaluateAll((handles) => handles.every((handle) => handle.getAttribute("aria-hidden") === "true" && handle.tabIndex === -1), ), ).toBe(true); await expectTargetSizeAsync(firstResizeHandles.first()); const southEastResizeHandle = firstItem.locator(':scope > [data-grid-resize-handle="se"]'); await expectLogicalTargetSizeAsync(southEastResizeHandle, 44); await expect(southEastResizeHandle).toHaveCSS("transform", "none"); const firstEastHandleBox = await expectBoundingBoxAsync( firstItem.locator(':scope > [data-grid-resize-handle="e"]'), ); const secondWestHandleBox = await expectBoundingBoxAsync( secondItem.locator(':scope > [data-grid-resize-handle="w"]'), ); const firstSouthEastHandleBox = await expectBoundingBoxAsync( firstItem.locator(':scope > [data-grid-resize-handle="se"]'), ); const secondSouthWestHandleBox = await expectBoundingBoxAsync( secondItem.locator(':scope > [data-grid-resize-handle="sw"]'), ); expect(firstEastHandleBox.x + firstEastHandleBox.width).toBeLessThanOrEqual(secondWestHandleBox.x + 0.5); expect(firstSouthEastHandleBox.x + firstSouthEastHandleBox.width).toBeLessThanOrEqual( secondSouthWestHandleBox.x + 0.5, ); const inertContent = firstItem.locator("[data-board-grid-inert-content]"); await expect(inertContent).toHaveAttribute("inert", ""); await expect(inertContent).toHaveCSS("pointer-events", "none"); await page.getByRole("button", { name: "Add board content" }).click(); await expect(page.getByRole("menuitem", { name: "New container", 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 mainGrid = page.locator(`[data-grid-section-id="${fixture.sectionId}"]`); const editorViewport = page.viewportSize(); if (!editorViewport) throw new Error("Expected a browser viewport"); expect((await expectBoundingBoxAsync(mainGrid)).height).toBeGreaterThanOrEqual(editorViewport.height - 1); const interactionSnapshot = await readEditorGridSnapshotAsync(mainGrid); await expectGridGrowsAtBoundaryAsync(page, canvas, mainGrid, firstItem); await firstItem.locator("[data-grid-item-content]").evaluate((element) => { const iframe = document.createElement("iframe"); iframe.dataset.e2eDragIframe = "true"; iframe.src = "about:blank"; iframe.style.width = "1px"; iframe.style.height = "1px"; iframe.style.pointerEvents = "none"; element.appendChild(iframe); }); const responsiveScale = await readCanvasScaleAsync(canvas); const responsiveDragBox = await expectBoundingBoxAsync(firstItem); const responsiveStartX = responsiveDragBox.x + responsiveDragBox.width * 0.7; const responsiveStartY = responsiveDragBox.y + responsiveDragBox.height * 0.4; await page.mouse.move(responsiveStartX, responsiveStartY); await page.mouse.down(); await page.mouse.move(responsiveStartX + logicalCellPitch * responsiveScale, responsiveStartY, { steps: 8 }); await expect(firstItem).toHaveAttribute("data-dnd-drag-source", "true"); await expect(page.locator('iframe[data-e2e-drag-iframe="true"]')).toHaveCount(1); await page.setViewportSize({ width: 1000, height: 1200 }); await expect(page.locator(`[data-grid-runtime="${gridRuntimeMarker}"]`)).toHaveCount(2); await expect(page.locator("body")).not.toHaveAttribute("data-board-grid-interacting"); await expect(firstItem).not.toHaveAttribute("data-dnd-drag-source"); await page.mouse.up(); await page .locator('iframe[data-e2e-drag-iframe="true"]') .evaluateAll((elements) => elements.forEach((element) => element.remove())); await page.setViewportSize({ width: 600, height: 1200 }); await expect(canvas).toHaveAttribute("data-canvas-overflow", "false"); await expect(canvas).toHaveCSS("overflow-x", "clip"); await expectDocumentNotHorizontallyScrollableAsync(page); for (const direction of ["n", "ne", "e", "se", "s", "sw", "w", "nw"] as const) { await expectTargetSizeAsync(firstItem.locator(`:scope > [data-grid-resize-handle="${direction}"]`)); } await page.setViewportSize({ width: 1920, height: 1200 }); await expect(page.locator(`[data-grid-runtime="${gridRuntimeMarker}"]`)).toHaveCount(3); await expect(canvas).toHaveAttribute("data-canvas-overflow", "false"); await expectDocumentNotHorizontallyScrollableAsync(page); await expect.poll(async () => await readEditorGridSnapshotAsync(mainGrid)).toEqual(interactionSnapshot); const settingsButton = firstItem.getByRole("button", { name: /^Settings for / }); await firstItem.hover(); const widgetHeader = firstItem.locator("[data-board-widget-header]"); await expect(widgetHeader).toBeVisible(); const settingsBox = await expectBoundingBoxAsync(settingsButton); const widgetHeaderBox = await expectBoundingBoxAsync(widgetHeader); const firstItemBox = await expectBoundingBoxAsync(firstItem); expect(settingsBox.y).toBeGreaterThanOrEqual(firstItemBox.y); expect(settingsBox.y + settingsBox.height).toBeLessThanOrEqual(firstItemBox.y + firstItemBox.height); expect(settingsBox.y).toBeLessThan(widgetHeaderBox.y + widgetHeaderBox.height); expect(settingsBox.y + settingsBox.height).toBeGreaterThan(widgetHeaderBox.y); await expect(settingsButton).toHaveCSS("border-radius", "4px"); await page.mouse.move(settingsBox.x + settingsBox.width / 2, settingsBox.y + settingsBox.height / 2); await page.mouse.down(); await page.mouse.move(settingsBox.x + settingsBox.width / 2 + 8, settingsBox.y + settingsBox.height / 2 + 8); await expect(firstItem).not.toHaveAttribute("data-dnd-drag-source"); await page.mouse.up(); await page.keyboard.press("Escape"); await expect.poll(async () => await readEditorGridSnapshotAsync(mainGrid)).toEqual(interactionSnapshot); const railBoxBeforeMainDrag = await expectBoundingBoxAsync(rail); const canvasScale = await readCanvasScaleAsync(canvas); await dragLocatorByFromOffsetAsync(page, firstItem, { deltaX: logicalCellPitch * canvasScale, deltaY: 0, startXRatio: 0.78, startYRatio: 0.31, }); await expect(firstItem).toHaveAttribute("data-grid-x", "1"); await expect(secondItem).toHaveAttribute("data-grid-x", "0"); await dragLocatorByTouchAsync(page, firstItem, 0, logicalCellPitch * canvasScale); await expect(firstItem).toHaveAttribute("data-grid-y", "1"); await dragLocatorByTouchAsync(page, firstItem, 0, -logicalCellPitch * canvasScale); await expect(firstItem).toHaveAttribute("data-grid-y", "0"); await expectGridEntryGeometrySettledAsync(firstItem); await dragLocatorByImmediateReleaseAsync(page, firstItem, 0, logicalCellPitch * canvasScale); await expect(firstItem).toHaveAttribute("data-grid-y", "1"); await expectGridEntryGeometrySettledAsync(firstItem); await dragLocatorByImmediateReleaseAsync(page, firstItem, 0, -logicalCellPitch * canvasScale); await expect(firstItem).toHaveAttribute("data-grid-y", "0"); await expectGridEntryGeometrySettledAsync(firstItem); await expectCancelledDragRestoresSnapshotAsync(page, mainGrid, firstItem, 0, logicalCellPitch * canvasScale * 2); await expectInvalidDropRestoresSnapshotAsync(page, mainGrid, firstItem); await expectImmediateOutsideReleaseRestoresSnapshotAsync(page, mainGrid, firstItem); await dragLocatorByAsync(page, 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 oversizedKeyboardEntry = getKeyboardEditorEntry(belowItem); await oversizedKeyboardEntry.focus(); await oversizedKeyboardEntry.press("Enter"); await oversizedKeyboardEntry.press("Shift+ArrowRight"); await oversizedKeyboardEntry.press("Escape"); await expect(belowItem).toHaveAttribute("data-grid-w", "3"); const nestedEditor = containerSection.locator(`[data-grid-section-id="${fixture.containerSectionId}"]`); const nestedGridBox = await expectBoundingBoxAsync(nestedEditor); await expectInvalidNestedDropRestoresSnapshotAsync( page, mainGrid, nestedEditor, belowItem, nestedGridBox.x + nestedGridBox.width / 2, nestedGridBox.y + logicalCellSize * canvasScale * 0.5, ); await expect(belowItem.locator("xpath=ancestor::*[@data-grid-section-id][1]")).toHaveAttribute( "data-grid-section-id", fixture.sectionId, ); await oversizedKeyboardEntry.focus(); await oversizedKeyboardEntry.press("Enter"); await oversizedKeyboardEntry.press("Shift+ArrowLeft"); await oversizedKeyboardEntry.press("Escape"); await expect(belowItem).toHaveAttribute("data-grid-w", "2"); await oversizedKeyboardEntry.focus(); await oversizedKeyboardEntry.press("Enter"); await oversizedKeyboardEntry.press("Shift+ArrowDown"); await oversizedKeyboardEntry.press("Shift+ArrowDown"); await oversizedKeyboardEntry.press("Escape"); await expect(belowItem).toHaveAttribute("data-grid-h", "3"); await expectInvalidNestedDropRestoresSnapshotAsync( page, mainGrid, nestedEditor, belowItem, nestedGridBox.x + nestedGridBox.width / 2, nestedGridBox.y + logicalCellSize * canvasScale * 0.5, ); await oversizedKeyboardEntry.focus(); await oversizedKeyboardEntry.press("Enter"); await oversizedKeyboardEntry.press("Shift+ArrowUp"); await oversizedKeyboardEntry.press("Shift+ArrowUp"); await oversizedKeyboardEntry.press("Escape"); await expect(belowItem).toHaveAttribute("data-grid-h", "1"); 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"); }); await dragLocatorToImmediateReleaseAsync( page, secondItem, nestedGridBox.x + logicalCellPitch * canvasScale * 1.5, nestedGridBox.y + logicalCellSize * canvasScale * 0.5, ); await expect(secondItem.locator("xpath=ancestor::*[@data-grid-section-id][1]")).toHaveAttribute( "data-grid-section-id", fixture.containerSectionId, ); await expect( secondItem.locator('[data-grid-item-content][data-e2e-portal-instance="second-item-content"]'), ).toBeVisible(); const packedSecondKeyboardEntry = getKeyboardEditorEntry(secondItem); await packedSecondKeyboardEntry.focus(); await packedSecondKeyboardEntry.press("Enter"); await packedSecondKeyboardEntry.press("Shift+ArrowDown"); await packedSecondKeyboardEntry.press("Escape"); await expect(secondItem).toHaveAttribute("data-grid-h", "2"); const packedSecondBox = await expectBoundingBoxAsync(secondItem); const initialMainGridBox = await expectBoundingBoxAsync(mainGrid); await page.mouse.move( packedSecondBox.x + packedSecondBox.width / 2, packedSecondBox.y + packedSecondBox.height / 2, ); await page.mouse.down(); await page.mouse.move(nestedGridBox.x + 1, packedSecondBox.y + packedSecondBox.height / 2, { steps: 8 }); await expect(mainGrid).toHaveAttribute("data-dnd-drop-target", "true"); await expect(nestedEditor).toHaveAttribute("data-dnd-drop-target", "false"); await page.mouse.move( initialMainGridBox.x + logicalCellSize * canvasScale * 0.5, initialMainGridBox.y + logicalCellSize * canvasScale * 0.5, { steps: 8 }, ); await page.mouse.up(); await expect(secondItem.locator("xpath=ancestor::*[@data-grid-section-id][1]")).toHaveAttribute( "data-grid-section-id", fixture.sectionId, ); await dragLocatorToImmediateReleaseAsync( page, secondItem, nestedGridBox.x + logicalCellPitch * canvasScale * 1.5, nestedGridBox.y + logicalCellSize * canvasScale * 0.5, ); await expect(secondItem.locator("xpath=ancestor::*[@data-grid-section-id][1]")).toHaveAttribute( "data-grid-section-id", fixture.containerSectionId, ); const nestedSouthResizeHandle = nestedItem.locator(':scope > [data-grid-resize-handle="s"]'); await expect(containerSection).toHaveAttribute("data-grid-section-chrome-active", "false"); await expect(nestedSouthResizeHandle).not.toHaveAttribute("data-grid-resize-disabled"); await dragLocatorByAsync(page, nestedSouthResizeHandle, 0, -logicalCellPitch * canvasScale); await expect(nestedItem).toHaveAttribute("data-grid-h", "1"); await expect(containerSection).toHaveAttribute("data-grid-h", "2"); const packedNestedKeyboardEntry = getKeyboardEditorEntry(nestedItem); await packedNestedKeyboardEntry.focus(); await packedNestedKeyboardEntry.press("Enter"); await packedNestedKeyboardEntry.press("Shift+ArrowDown"); await packedNestedKeyboardEntry.press("Escape"); await expect(nestedItem).toHaveAttribute("data-grid-h", "2"); await nestedItem.locator("[data-grid-item-content]").evaluate((element) => { element.setAttribute("data-e2e-container-child", "nested-item-content"); }); const containerGrip = containerSection.locator("[data-grid-container-drag-handle]"); await expectTargetSizeAsync(containerGrip); const railGridBox = await expectBoundingBoxAsync(railGrid); await dragHandleToGridPositionAsync(page, containerGrip, containerSection, railGrid, 0, 0, canvasScale); await expect(containerSection.locator("xpath=ancestor::aside[1]")).toHaveAttribute("data-board-gutter", "left"); await expect(secondItem.locator("xpath=ancestor::*[@data-grid-section-id][1]")).toHaveAttribute( "data-grid-section-id", fixture.containerSectionId, ); await expect( nestedItem.locator('[data-grid-item-content][data-e2e-container-child="nested-item-content"]'), ).toBeVisible(); await Promise.all([ page.waitForResponse( (response) => response.url().includes("/api/trpc/board.saveBoard") && response.status() === 200, ), editToggle.click(), ]); await expect(page.locator(`[data-grid-runtime="${gridRuntimeMarker}"]`)).toHaveCount(0); await page.reload(); await expect(canvas).toHaveAttribute("data-board-hydrated", "true"); await expect(secondItem).toHaveCount(1); const reloadedSecondItemGrid = secondItem.locator("xpath=ancestor::*[@data-grid-section-id][1]"); await expect(reloadedSecondItemGrid).toHaveCount(1); await expect(reloadedSecondItemGrid).toHaveAttribute("data-grid-section-id", fixture.containerSectionId); await expect(containerSection.locator("xpath=ancestor::aside[1]")).toHaveAttribute("data-board-gutter", "left"); await editToggle.click(); await expect(page.locator(`[data-grid-runtime="${gridRuntimeMarker}"]`)).toHaveCount(3); await dragHandleToGridPositionAsync(page, containerGrip, containerSection, mainGrid, 2, 0, canvasScale); await expect(containerSection.locator("xpath=ancestor::*[@data-grid-section-id][1]")).toHaveAttribute( "data-grid-section-id", fixture.sectionId, ); await expect(containerSection).toHaveAttribute("data-grid-x", "2"); const containerKeyboardEntry = getKeyboardEditorEntry(containerSection); await containerKeyboardEntry.focus(); await containerKeyboardEntry.press("Enter"); await expect(containerKeyboardEntry).toHaveAttribute("data-keyboard-editing", "true"); await getKeyboardEditorEntry(nestedItem).focus(); await expect(containerKeyboardEntry).toHaveAttribute("data-keyboard-editing", "false"); await secondItem.locator("[data-grid-item-content]").evaluate((element) => { element.setAttribute("data-e2e-portal-instance", "second-item-content"); }); const mainGridBox = await expectBoundingBoxAsync(mainGrid); await dragLocatorToAsync( page, secondItem, mainGridBox.x + logicalCellSize * canvasScale * 0.5, mainGridBox.y + logicalCellSize * canvasScale * 0.5, ); await expect(secondItem.locator("xpath=ancestor::*[@data-grid-section-id][1]")).toHaveAttribute( "data-grid-section-id", fixture.sectionId, ); await expect( secondItem.locator('[data-grid-item-content][data-e2e-portal-instance="second-item-content"]'), ).toBeVisible(); await expect(containerSection).toHaveAttribute("data-grid-section-chrome-active", "false"); await expect(nestedSouthResizeHandle).not.toHaveAttribute("data-grid-resize-disabled"); await dragLocatorByAsync(page, nestedSouthResizeHandle, 0, -logicalCellPitch * canvasScale); await expect(nestedItem).toHaveAttribute("data-grid-h", "1"); await expect(containerSection).toHaveAttribute("data-grid-h", "2"); await containerGrip.click(); await expect(containerSection).toHaveAttribute("data-grid-section-chrome-active", "true"); await dragLocatorToAsync( page, 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(secondItem).toHaveClass(/\bboard-grid-entry\b/); 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(getKeyboardEditorEntry(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(getKeyboardEditorEntry(secondItem)).toBeFocused(); await expect( secondItem.locator('[data-grid-item-content][data-e2e-portal-instance="second-item-content"]'), ).toBeVisible(); const railKeyboardEntry = getKeyboardEditorEntry(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 eastResizeHandle = containerSection.locator(':scope > [data-grid-resize-handle="e"]'); await expectTargetSizeAsync(eastResizeHandle); await expect(eastResizeHandle).not.toHaveAttribute("data-grid-resize-disabled"); await railKeyboardEntry.focus(); await railKeyboardEntry.press("Enter"); const mixedInputSnapshot = await readEditorGridSnapshotAsync(mainGrid); const mixedInputRailSnapshot = await readEditorGridSnapshotAsync(railGrid); const mixedInputHandleBox = await expectBoundingBoxAsync(eastResizeHandle); await page.mouse.move( mixedInputHandleBox.x + mixedInputHandleBox.width / 2, mixedInputHandleBox.y + mixedInputHandleBox.height / 2, ); await page.mouse.down(); await expect(page.locator("body")).toHaveAttribute("data-board-grid-interacting", "resize"); await railKeyboardEntry.press("ArrowLeft"); await expect(railKeyboardEntry).toHaveAttribute("data-keyboard-editing", "false"); await page.keyboard.press("Escape"); await page.mouse.up(); await expect.poll(async () => await readEditorGridSnapshotAsync(mainGrid)).toEqual(mixedInputSnapshot); await expect.poll(async () => await readEditorGridSnapshotAsync(railGrid)).toEqual(mixedInputRailSnapshot); await expectTouchResizeCancellationRestoresAsync( page, mainGrid, eastResizeHandle, -logicalCellPitch * canvasScale, 0, ); await expectGridEntryGeometrySettledAsync(containerSection); const eastResizeBox = await expectBoundingBoxAsync(eastResizeHandle); const eastResizeStart = { x: eastResizeBox.x + eastResizeBox.width / 2, y: eastResizeBox.y + eastResizeBox.height / 2, }; await page.mouse.move(eastResizeStart.x, eastResizeStart.y); await page.mouse.down(); await page.mouse.move(eastResizeStart.x - logicalCellPitch * canvasScale * 0.35, eastResizeStart.y, { steps: 6, }); const resizeOutline = containerSection.locator(":scope > [data-grid-resize-outline]"); const resizePlaceholder = mainGrid.locator(`[data-grid-placeholder-for="${fixture.containerSectionId}"]`); await expect(resizeOutline).toBeVisible(); await expect(resizeOutline).toHaveAttribute("data-grid-resize-valid", "true"); await expect(resizePlaceholder).toHaveAttribute("data-grid-placeholder-mode", "resize"); await expect(resizePlaceholder).toHaveAttribute("data-grid-w", "2"); await expect(containerSection).toHaveAttribute("data-grid-w", "2"); const originalResizeWidth = (await expectBoundingBoxAsync(containerSection)).width; const continuousResizeWidth = (await expectBoundingBoxAsync(resizeOutline)).width; expect(continuousResizeWidth).toBeLessThan(originalResizeWidth); expect(continuousResizeWidth).toBeGreaterThan(logicalCellSize * canvasScale); await page.mouse.move(eastResizeStart.x - logicalCellPitch * canvasScale, eastResizeStart.y, { steps: 6 }); await expect(containerSection).toHaveAttribute("data-grid-w", "1"); await expect(resizePlaceholder).toHaveAttribute("data-grid-w", "1"); expect((await expectBoundingBoxAsync(resizeOutline)).width).toBeCloseTo(logicalCellSize * canvasScale, 1); const liveContainerBox = await expectBoundingBoxAsync(containerSection); expect(liveContainerBox.width).toBeCloseTo(logicalCellSize * canvasScale, 1); await expect(containerSection.locator(":scope > .board-grid-content-mount")).toHaveCSS("overflow", "clip"); await page.mouse.up(); await expect(containerSection).toHaveAttribute("data-grid-w", "1"); await expect(resizeOutline).toHaveCount(0); await expect(containerSection).toHaveAttribute("data-grid-section-chrome-active", "true"); await expect(eastResizeHandle).not.toHaveAttribute("data-grid-resize-disabled"); await resizeLocatorByImmediateReleaseAsync(page, eastResizeHandle, logicalCellPitch * canvasScale, 0); await expect(containerSection).toHaveAttribute("data-grid-w", "2"); await expectGridEntryGeometrySettledAsync(containerSection); await expect(eastResizeHandle).not.toHaveAttribute("data-grid-resize-disabled"); const expandingEastResizeBox = await expectBoundingBoxAsync(eastResizeHandle); const expandingEastResizeStart = { x: expandingEastResizeBox.x + expandingEastResizeBox.width / 2, y: expandingEastResizeBox.y + expandingEastResizeBox.height / 2, }; const expandingContainerBox = await expectBoundingBoxAsync(containerSection); const eastHandleGrabOffset = expandingContainerBox.x + expandingContainerBox.width - expandingEastResizeStart.x; const continuousExpansion = logicalCellPitch * canvasScale * 1.2; await page.mouse.move(expandingEastResizeStart.x, expandingEastResizeStart.y); await page.mouse.down(); await expect(page.locator("body")).toHaveAttribute("data-board-grid-interacting", "resize"); await page.mouse.move(expandingEastResizeStart.x + continuousExpansion, expandingEastResizeStart.y, { steps: 12, }); await expect(resizeOutline).toBeVisible(); const expandedOutlineBox = await expectBoundingBoxAsync(resizeOutline); expect(expandedOutlineBox.x + expandedOutlineBox.width).toBeCloseTo( expandingEastResizeStart.x + continuousExpansion + eastHandleGrabOffset, 1, ); expect( await resizeOutline.evaluate((element) => { const probe = document.createElement("span"); probe.style.color = "var(--mantine-primary-color-filled)"; element.appendChild(probe); const usesPrimaryColor = getComputedStyle(element).borderTopColor === getComputedStyle(probe).color; probe.remove(); return usesPrimaryColor; }), ).toBe(true); await page.keyboard.press("Escape"); await expect(resizeOutline).toHaveCount(0); await expect(page.locator("body")).not.toHaveAttribute("data-board-grid-interacting"); await page.mouse.up(); await expect(containerSection).toHaveAttribute("data-grid-w", "2"); const northResizeHandle = containerSection.locator(':scope > [data-grid-resize-handle="n"]'); await expectTargetSizeAsync(northResizeHandle); await dragLocatorByAsync(page, northResizeHandle, 0, logicalCellPitch * canvasScale); await expect(containerSection).toHaveAttribute("data-grid-y", "1"); await expect(containerSection).toHaveAttribute("data-grid-h", "1"); await expectGridEntryGeometrySettledAsync(containerSection); const minimumSectionBox = await expectBoundingBoxAsync(containerSection); const minimumHandleBox = await expectBoundingBoxAsync(northResizeHandle); 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(containerSection); expect(Math.abs(constrainedSectionBox.height - minimumSectionBox.height)).toBeLessThan(1); expect(Math.abs(constrainedSectionBox.y - minimumSectionBox.y)).toBeLessThan(1); await page.mouse.up(); await expect(containerSection).toHaveAttribute("data-grid-y", "1"); await expect(containerSection).toHaveAttribute("data-grid-h", "1"); await dragLocatorByAsync(page, northResizeHandle, 0, -logicalCellPitch * canvasScale); await expect(containerSection).toHaveAttribute("data-grid-y", "0"); await expect(containerSection).toHaveAttribute("data-grid-h", "2"); const southResizeHandle = containerSection.locator(':scope > [data-grid-resize-handle="s"]'); const gridHeightBeforeSouthResize = (await expectBoundingBoxAsync(mainGrid)).height; const southResizeBox = await expectBoundingBoxAsync(southResizeHandle); const southResizeStart = { x: southResizeBox.x + southResizeBox.width / 2, y: southResizeBox.y + southResizeBox.height / 2, }; await page.mouse.move(southResizeStart.x, southResizeStart.y); await page.mouse.down(); await page.mouse.move(southResizeStart.x, southResizeStart.y + logicalCellPitch * canvasScale, { steps: 8 }); await expect(containerSection).toHaveAttribute("data-grid-h", "3"); await expect(belowItem).toHaveAttribute("data-grid-y", "3"); await expect .poll(async () => (await expectBoundingBoxAsync(mainGrid)).height) .toBeGreaterThanOrEqual(gridHeightBeforeSouthResize); await page.mouse.up(); expect( gridLayoutsOverlap(await readGridLayoutAsync(containerSection), await readGridLayoutAsync(belowItem)), ).toBe(false); // Grow the canvas further to exercise sustained document auto-scroll. await dragLocatorByAsync(page, southResizeHandle, 0, logicalCellPitch * canvasScale * 6); await expect(containerSection).toHaveAttribute("data-grid-h", "9"); await expect(belowItem).toHaveAttribute("data-grid-y", "9"); await firstItem.evaluate((element) => element.scrollIntoView({ block: "center", behavior: "instant" })); const autoScrollStartBox = await expectBoundingBoxAsync(firstItem); const autoScrollStart = { x: autoScrollStartBox.x + autoScrollStartBox.width * 0.63, y: autoScrollStartBox.y + autoScrollStartBox.height * 0.42, }; const viewport = page.viewportSize(); if (!viewport) throw new Error("Expected a browser viewport"); const scrollBeforeDrag = await page.evaluate(() => window.scrollY); await page.mouse.move(autoScrollStart.x, autoScrollStart.y); await page.mouse.down(); await page.mouse.move(autoScrollStart.x + 8, autoScrollStart.y); await expect(firstItem).toHaveAttribute("data-dnd-drag-source", "true"); const autoScrollTarget = { x: autoScrollStart.x, y: viewport.height - 12 }; await page.mouse.move(autoScrollTarget.x, autoScrollTarget.y, { steps: 3 }); await expect.poll(async () => await page.evaluate(() => window.scrollY)).toBeGreaterThan(scrollBeforeDrag); const autoScrollPreview = await page.evaluate( ({ itemId, x, y }) => { const placeholder = document.querySelector(`[data-grid-placeholder-for="${itemId}"]`); const targetGrid = placeholder?.closest("[data-grid-section-id]"); const preview = { row: placeholder?.dataset.gridY, revision: targetGrid?.dataset.dndPreviewRevision, }; document.dispatchEvent( new PointerEvent("pointerup", { bubbles: true, pointerId: 1, pointerType: "mouse", isPrimary: true, button: 0, buttons: 0, clientX: x, clientY: y, }), ); return preview; }, { itemId: fixture.firstItemId, ...autoScrollTarget }, ); expect(autoScrollPreview.row).toBeDefined(); expect(autoScrollPreview.revision).toBeDefined(); await page.mouse.up(); await expect(firstItem).toHaveAttribute("data-grid-y", autoScrollPreview.row ?? "missing"); await expect(page.locator("body")).not.toHaveAttribute("data-board-grid-interacting"); await expect(page.locator(`[data-grid-placeholder-for="${fixture.firstItemId}"]`)).toHaveCount(0); expect(Number(autoScrollPreview.row)).toBeGreaterThan(0); await firstItem.evaluate((element) => element.scrollIntoView({ block: "center", behavior: "instant" })); const autoScrollKeyboardEntry = getKeyboardEditorEntry(firstItem); await autoScrollKeyboardEntry.focus(); await autoScrollKeyboardEntry.press("Enter"); for (let row = Number(autoScrollPreview.row); row > 0; row -= 1) { await autoScrollKeyboardEntry.press("ArrowUp"); } await autoScrollKeyboardEntry.press("Escape"); await expect(firstItem).toHaveAttribute("data-grid-y", "0"); // 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 expectGridEntryGeometrySettledAsync(firstItem); await firstItem.evaluate((element) => element.scrollIntoView({ block: "center", behavior: "instant" })); await dragLocatorByImmediateReleaseAsync(page, firstItem, 0, logicalCellPitch * canvasScale + 2); await expect(firstItem).toHaveAttribute("data-grid-y", String(targetRow)); } await expect(firstItem).toHaveAttribute("data-grid-y", "8"); await expect(firstItem).toHaveAttribute("data-grid-x", "4"); expect(gridLayoutsOverlap(await readGridLayoutAsync(firstItem), await readGridLayoutAsync(belowItem))).toBe( false, ); await page.evaluate(() => window.scrollTo({ top: 500, behavior: "auto" })); const stickyRailY = (await expectBoundingBoxAsync(rail)).y; await page.evaluate(() => window.scrollTo({ top: 600, behavior: "auto" })); await expect.poll(async () => (await expectBoundingBoxAsync(rail)).y).toBeCloseTo(stickyRailY, 0); await page.evaluate(() => window.scrollTo({ top: 0, behavior: "auto" })); await setDocumentZoomAsync(page, 1.25); await expectFixedLogicalTileAsync(logicalTile); await expectUniformVisualScaleAsync(logicalTile); await expectTargetSizeAsync(firstItem); await expectTargetSizeAsync(firstItem.locator(':scope > [data-grid-resize-handle="se"]')); await firstItem.evaluate((element) => element.scrollIntoView({ block: "center", behavior: "instant" })); const zoomedEastResizeHandle = firstItem.locator(':scope > [data-grid-resize-handle="e"]'); const zoomedOneColumnScale = (await expectBoundingBoxAsync(firstItem)).width / logicalCellSize; await dragLocatorByAsync(page, zoomedEastResizeHandle, logicalCellPitch * zoomedOneColumnScale, 0); await expect(firstItem).toHaveAttribute("data-grid-w", "2"); await expectGridEntryGeometrySettledAsync(firstItem); const zoomedTwoColumnScale = (await expectBoundingBoxAsync(firstItem)).width / (logicalCellSize * 2 + LOGICAL_GRID_GAP); await dragLocatorByAsync(page, zoomedEastResizeHandle, -logicalCellPitch * zoomedTwoColumnScale, 0); await expect(firstItem).toHaveAttribute("data-grid-w", "1"); await expect(firstItem).toHaveAttribute("data-grid-x", "4"); await expectGridEntryGeometrySettledAsync(firstItem); await expectImmediateOutsideReleaseRestoresSnapshotAsync(page, mainGrid, firstItem); await expectGridEntryGeometrySettledAsync(firstItem); const zoomedDragSourceBox = await expectBoundingBoxAsync(firstItem); await dragLocatorByImmediateReleaseAsync( page, firstItem, 0, -logicalCellPitch * zoomedOneColumnScale, async () => { const overlayBox = await expectBoundingBoxAsync(page.locator(".board-grid-drag-overlay[data-dnd-dragging]")); const overlayCardBox = await expectBoundingBoxAsync(page.locator(".board-grid-drag-overlay-card")); expect(Math.abs(overlayBox.x - zoomedDragSourceBox.x)).toBeLessThan(1); expect(Math.abs(overlayBox.y - zoomedDragSourceBox.y)).toBeLessThan(1); expect(Math.abs(overlayBox.width - zoomedDragSourceBox.width)).toBeLessThan(1); expect(Math.abs(overlayBox.height - zoomedDragSourceBox.height)).toBeLessThan(1); expect(Math.abs(overlayCardBox.x - overlayBox.x)).toBeLessThan(1); expect(Math.abs(overlayCardBox.y - overlayBox.y)).toBeLessThan(1); expect(Math.abs(overlayCardBox.width - overlayBox.width)).toBeLessThan(1); expect(Math.abs(overlayCardBox.height - overlayBox.height)).toBeLessThan(1); }, ); await expect(firstItem).toHaveAttribute("data-grid-x", "4"); await expect(firstItem).toHaveAttribute("data-grid-y", "7"); await expectGridEntryGeometrySettledAsync(firstItem); await dragLocatorByImmediateReleaseAsync(page, firstItem, 0, logicalCellPitch * zoomedOneColumnScale); await expect(firstItem).toHaveAttribute("data-grid-x", "4"); await expect(firstItem).toHaveAttribute("data-grid-y", "8"); await expect(page.locator("body")).not.toHaveAttribute("data-board-grid-interacting"); await setDocumentZoomAsync(page, 1); await captureBoardScreenshotAsync(canvas, screenshotDirectory, "board-grid-edit-mode.png"); const containerLayoutBeforeDelete = await readGridLayoutAsync(containerSection); await containerSection.getByRole("button", { name: "Settings for Nested box" }).click(); await page .getByRole("menuitem", { name: "Remove container", exact: true, }) .click(); await page .getByRole("dialog", { name: "Remove container", }) .getByRole("button", { name: "Confirm", exact: true, }) .click(); await expect(containerSection).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(containerLayoutBeforeDelete.x)); await expect(nestedItem).toHaveAttribute("data-grid-y", String(containerLayoutBeforeDelete.y)); await page.getByTestId("board-edit-mode-toggle").click(); await expect(page.locator(`[data-grid-runtime="${gridRuntimeMarker}"]`)).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 reloadedContainerSection = reloadedCanvas.locator(`[data-grid-item-id="${fixture.containerSectionId}"]`); 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(reloadedContainerSection).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 desktopLayoutGroup = page.getByRole("group", { name: "Desktop" }); const gutterPreview = desktopLayoutGroup.getByLabel("Dashboard layout preview"); await expect(gutterPreview).toBeVisible(); const rightGutterSwitch = desktopLayoutGroup.getByRole("switch", { name: "Right sidebar" }); await rightGutterSwitch.check(); const rightGutterSlider = desktopLayoutGroup.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); await page.getByTestId("board-edit-mode-toggle").click(); await expect(emptyRightGutter).toHaveAttribute("data-board-editing", "true"); expect( await emptyRightGutter.evaluate((element) => ({ content: getComputedStyle(element, "::before").content.replaceAll('"', ""), insetInlineStart: getComputedStyle(element, "::before").insetInlineStart, top: getComputedStyle(element, "::before").top, })), ).toEqual({ content: "Right dashboard rail", insetInlineStart: "0px", top: "0px" }); expect(pageErrors).toEqual([]); } finally { runtimeResources?.stop(); await browser?.close(); await homarrContainer.stop(); } }, 300_000); }); const seedBoardGridAsync = async (db: SqliteDatabase, creatorId: string) => { const boardId = createId(); const boardName = "grid-e2e"; const layoutId = createId(); const compactLayoutId = createId(); const sectionId = createId(); const railSectionId = createId(); const rightSectionId = createId(); const containerSectionId = createId(); const firstItemId = createId(); const secondItemId = createId(); const belowItemId = createId(); const nestedItemId = createId(); const railItemId = createId(); const nestedAppId = 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, leftGutterColumnCount: 2, breakpoint: 1200, }, { id: compactLayoutId, name: "Compact", boardId, columnCount: 4, breakpoint: 0, }, ]); await db.insert(sqliteSchema.sections).values([ { id: sectionId, boardId, kind: "empty", xOffset: 0, yOffset: 0, }, { id: railSectionId, boardId, kind: "empty", xOffset: -1, yOffset: 0, }, { id: rightSectionId, boardId, kind: "empty", xOffset: 1, yOffset: 0, }, { id: containerSectionId, boardId, kind: "container", options: stringifySuperJSON({ title: "Nested box", customCssClasses: [], borderColor: "", showLabel: true, collapsible: true, showOpenAll: true, }), }, ]); await db.insert(sqliteSchema.sectionLayouts).values([ { sectionId: containerSectionId, layoutId, parentSectionId: sectionId, xOffset: 2, yOffset: 0, width: 2, height: 2, }, { sectionId: containerSectionId, layoutId: compactLayoutId, parentSectionId: sectionId, xOffset: 2, yOffset: 0, width: 2, height: 2, }, ]); await db.insert(sqliteSchema.apps).values({ id: nestedAppId, name: "Interactive nested app", iconUrl: "/favicon.ico", href: "#board-app-opened", }); await db.insert(sqliteSchema.items).values([ { id: firstItemId, boardId, kind: "clock", options: stringifySuperJSON({ is24HourFormat: true }), }, { id: secondItemId, boardId, kind: "clock", options: stringifySuperJSON({ is24HourFormat: true }), advancedOptions: stringifySuperJSON({ title: null, customCssClasses: ["e2e-widget-overflow"], borderColor: "", }), }, { id: belowItemId, boardId, kind: "clock", options: stringifySuperJSON({ is24HourFormat: true }), }, { id: nestedItemId, boardId, kind: "app", options: stringifySuperJSON({ appId: nestedAppId, descriptionDisplayMode: "hidden", layout: "column", openInNewTab: false, pingEnabled: false, showTitle: 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: containerSectionId, layoutId, xOffset: 0, yOffset: 0, width: 1, height: 2, }, { itemId: railItemId, sectionId: railSectionId, layoutId, xOffset: 0, yOffset: 0, width: 1, height: 1, }, { itemId: firstItemId, sectionId, layoutId: compactLayoutId, xOffset: 0, yOffset: 0, width: 1, height: 1, }, { itemId: secondItemId, sectionId, layoutId: compactLayoutId, xOffset: 1, yOffset: 0, width: 1, height: 1, }, { itemId: belowItemId, sectionId, layoutId: compactLayoutId, xOffset: 0, yOffset: 2, width: 2, height: 1, }, { itemId: nestedItemId, sectionId: containerSectionId, layoutId: compactLayoutId, xOffset: 0, yOffset: 0, width: 1, height: 2, }, { itemId: railItemId, sectionId: railSectionId, layoutId: compactLayoutId, xOffset: 0, yOffset: 0, width: 1, height: 1, }, ]); return { boardName, compactLayoutId, layoutId, sectionId, railSectionId, containerSectionId, 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 Promise.all([ page.waitForURL(`${baseUrl}${callbackPath}`, { timeout: 30_000, waitUntil: "commit" }), page.locator("button[type='submit']").click(), ]); }; 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 = "dnd-kit" | "runtime-marker"; interface RuntimeResource { phase: RuntimeResourcePhase; url: string; hasRuntimeMarker: boolean; hasDndKitPayload: 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(), hasRuntimeMarker: body.includes(Buffer.from(gridRuntimeMarker)), hasDndKitPayload: body.includes(Buffer.from(dndKitPayloadMarker)), }); }) .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 === "runtime-marker" ? "hasRuntimeMarker" : "hasDndKitPayload"; 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 expectGridEntryGeometrySettledAsync = async (entry: Locator) => { let previous: Awaited> = null; let stableFrames = 0; await expect .poll( async () => { const current = await entry.boundingBox(); if (!current) return false; const isStable = previous !== null && Math.abs(current.x - previous.x) < 0.25 && Math.abs(current.y - previous.y) < 0.25 && Math.abs(current.width - previous.width) < 0.25 && Math.abs(current.height - previous.height) < 0.25; stableFrames = isStable ? stableFrames + 1 : 0; previous = current; return stableFrames >= 3; }, { intervals: [16] }, ) .toBe(true); }; 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 entry = await getGridEntryLocatorAsync(locator); await expectGridEntryGeometrySettledAsync(entry); const box = await expectBoundingBoxAsync(locator); await dragLocatorToAsync(page, locator, box.x + box.width / 2 + deltaX, box.y + box.height / 2 + deltaY); }; const dragHandleToGridPositionAsync = async ( page: Page, handle: Locator, entry: Locator, grid: Locator, x: number, y: number, scale: number, ) => { await expectGridEntryGeometrySettledAsync(entry); const handleBox = await expectBoundingBoxAsync(handle); const entryBox = await expectBoundingBoxAsync(entry); const gridBox = await expectBoundingBoxAsync(grid); const grabOffset = { x: handleBox.x + handleBox.width / 2 - entryBox.x, y: handleBox.y + handleBox.height / 2 - entryBox.y, }; await dragLocatorToAsync( page, handle, gridBox.x + x * logicalCellPitch * scale + grabOffset.x, gridBox.y + y * logicalCellPitch * scale + grabOffset.y, ); }; interface OffsetDragInput { deltaX: number; deltaY: number; startXRatio: number; startYRatio: number; } const dragLocatorByFromOffsetAsync = async ( page: Page, locator: Locator, { deltaX, deltaY, startXRatio, startYRatio }: OffsetDragInput, ) => { const itemId = await locator.getAttribute("data-grid-item-id"); expect(itemId).not.toBeNull(); if (!itemId) throw new Error("Expected draggable grid entry id"); const grid = locator.locator("xpath=ancestor::*[@data-grid-section-id][1]"); const box = await expectBoundingBoxAsync(locator); const startX = box.x + box.width * startXRatio; const startY = box.y + box.height * startYRatio; const targetX = startX + deltaX; const targetY = startY + deltaY; const expectedOffset = { x: startX - box.x, y: startY - box.y }; await page.mouse.move(startX, startY); await page.mouse.down(); await page.mouse.move(targetX, targetY, { steps: 12 }); await expect(locator).toHaveAttribute("data-dnd-active", "true"); await expect(locator).toHaveAttribute("data-dnd-drag-source", "true"); await expect(grid).toHaveAttribute("data-dnd-drop-target", "true"); await expect(grid).toHaveAttribute("data-dnd-drop-valid", "true"); await expect(page.locator(`[data-grid-placeholder-for="${itemId}"]`)).toHaveCount(1); const dragOverlay = page.locator("[data-dnd-overlay][data-dnd-dragging]"); await expect(dragOverlay).toBeVisible(); await expect .poll(async () => { const draggedBox = await dragOverlay.evaluate((element) => { const rectangle = element.getBoundingClientRect(); return { x: rectangle.x, y: rectangle.y, width: rectangle.width, height: rectangle.height, }; }); if (draggedBox.width <= 0 || draggedBox.height <= 0) return false; const actualOffset = { x: targetX - draggedBox.x, y: targetY - draggedBox.y, }; return Math.abs(actualOffset.x - expectedOffset.x) < 2 && Math.abs(actualOffset.y - expectedOffset.y) < 2; }) .toBe(true); await page.mouse.up(); await expect(locator).not.toHaveAttribute("data-dnd-active"); await expect(locator).not.toHaveAttribute("data-dnd-drag-source"); await expect(dragOverlay).toHaveCount(0); await expect(grid).toHaveAttribute("data-dnd-drop-target", "false"); await expect(grid).not.toHaveAttribute("data-dnd-drop-valid"); await expect(page.locator(`[data-grid-placeholder-for="${itemId}"]`)).toHaveCount(0); }; const expectCancelledDragRestoresSnapshotAsync = async ( page: Page, grid: Locator, locator: Locator, deltaX: number, deltaY: number, ) => { const snapshot = await readEditorGridSnapshotAsync(grid); const itemId = await locator.getAttribute("data-grid-item-id"); expect(itemId).not.toBeNull(); if (!itemId) throw new Error("Expected draggable grid entry id"); const box = await expectBoundingBoxAsync(locator); const startX = box.x + box.width * 0.63; const startY = box.y + box.height * 0.42; await page.mouse.move(startX, startY); await page.mouse.down(); await page.mouse.move(startX + deltaX, startY + deltaY, { steps: 12 }); await expect(locator).toHaveAttribute("data-dnd-drag-source", "true"); await expect(grid).toHaveAttribute("data-dnd-drop-valid", "true"); await expect(page.locator(`[data-grid-placeholder-for="${itemId}"]`)).toHaveCount(1); await page.keyboard.press("Escape"); await page.mouse.up(); await expect(page.locator("body")).not.toHaveAttribute("data-board-grid-interacting"); await expect(locator).not.toHaveAttribute("data-dnd-active"); await expect(locator).not.toHaveAttribute("data-dnd-drag-source"); await expect(grid).toHaveAttribute("data-dnd-drop-target", "false"); await expect(grid).not.toHaveAttribute("data-dnd-drop-valid"); await expect(page.locator(`[data-grid-placeholder-for="${itemId}"]`)).toHaveCount(0); await expect.poll(async () => await readEditorGridSnapshotAsync(grid)).toEqual(snapshot); }; const expectInvalidDropRestoresSnapshotAsync = async (page: Page, grid: Locator, locator: Locator) => { const snapshot = await readEditorGridSnapshotAsync(grid); 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(startX + 8, startY + 8, { steps: 2 }); await expect(locator).toHaveAttribute("data-dnd-drag-source", "true"); await page.mouse.move(2, 2, { steps: 12 }); await expect(page.locator("[data-grid-placeholder-for]")).toHaveCount(0); await expect(grid).toHaveAttribute("data-dnd-drop-target", "false"); await page.mouse.up(); await expect(page.locator("body")).not.toHaveAttribute("data-board-grid-interacting"); await expect(locator).not.toHaveAttribute("data-dnd-active"); await expect(locator).not.toHaveAttribute("data-dnd-drag-source"); await expect.poll(async () => await readEditorGridSnapshotAsync(grid)).toEqual(snapshot); }; const expectImmediateOutsideReleaseRestoresSnapshotAsync = async (page: Page, grid: Locator, locator: Locator) => { const snapshot = await readEditorGridSnapshotAsync(grid); const itemId = await locator.getAttribute("data-grid-item-id"); expect(itemId).not.toBeNull(); if (!itemId) throw new Error("Expected draggable grid entry id"); const box = await expectBoundingBoxAsync(locator); const start = { x: box.x + box.width * 0.63, y: box.y + box.height * 0.42 }; await page.mouse.move(start.x, start.y); await page.mouse.down(); await page.mouse.move(start.x + 8, start.y); await expect(locator).toHaveAttribute("data-dnd-drag-source", "true"); await expect(page.locator(`[data-grid-placeholder-for="${itemId}"]`)).toHaveCount(1); await page.evaluate(() => { document.dispatchEvent( new PointerEvent("pointerup", { bubbles: true, pointerId: 1, pointerType: "mouse", isPrimary: true, button: 0, buttons: 0, clientX: 2, clientY: 2, }), ); }); await page.mouse.up(); await expect(page.locator("body")).not.toHaveAttribute("data-board-grid-interacting"); await expect(page.locator(`[data-grid-placeholder-for="${itemId}"]`)).toHaveCount(0); await expect.poll(async () => await readEditorGridSnapshotAsync(grid)).toEqual(snapshot); }; const expectGridGrowsAtBoundaryAsync = async (page: Page, canvas: Locator, grid: Locator, locator: Locator) => { const snapshot = await readEditorGridSnapshotAsync(grid); const gridBox = await expectBoundingBoxAsync(grid); const canvasBox = await expectBoundingBoxAsync(canvas); const itemBox = await expectBoundingBoxAsync(locator); const start = { x: itemBox.x + itemBox.width * 0.63, y: itemBox.y + itemBox.height * 0.42 }; await page.mouse.move(start.x, start.y); await page.mouse.down(); await page.mouse.move(start.x + 8, start.y); await expect(locator).toHaveAttribute("data-dnd-drag-source", "true"); await expect.poll(async () => (await expectBoundingBoxAsync(grid)).height).toBeGreaterThan(gridBox.height); await page.mouse.move(gridBox.x + gridBox.width / 2, gridBox.y + gridBox.height + logicalCellPitch / 2, { steps: 12, }); await expect(page.locator("[data-grid-placeholder-for]")).toHaveCount(1); const activeGridBox = await expectBoundingBoxAsync(grid); expect(activeGridBox.height).toBeGreaterThan(gridBox.height); await page.mouse.move(canvasBox.x + canvasBox.width + 1000, start.y, { steps: 12 }); const overlayBox = await expectBoundingBoxAsync(page.locator(".board-grid-drag-overlay[data-dnd-dragging]")); expect(overlayBox.x + overlayBox.width).toBeLessThanOrEqual(canvasBox.x + canvasBox.width + 1); await page.keyboard.press("Escape"); await page.mouse.up(); await expect(page.locator("body")).not.toHaveAttribute("data-board-grid-interacting"); await expect.poll(async () => await readEditorGridSnapshotAsync(grid)).toEqual(snapshot); await locator.evaluate((element) => element.scrollIntoView({ block: "center", behavior: "instant" })); await expectGridEntryGeometrySettledAsync(locator); }; const expectInvalidNestedDropRestoresSnapshotAsync = async ( page: Page, sourceGrid: Locator, targetGrid: Locator, locator: Locator, targetX: number, targetY: number, ) => { const snapshot = await readEditorGridSnapshotAsync(sourceGrid); const box = await expectBoundingBoxAsync(locator); await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.down(); await page.mouse.move(targetX, targetY, { steps: 12 }); await expect(locator).toHaveAttribute("data-dnd-drag-source", "true"); await expect(targetGrid).toHaveAttribute("data-dnd-drop-target", "true"); await expect(targetGrid).toHaveAttribute("data-dnd-drop-valid", "false"); await expect(sourceGrid).toHaveAttribute("data-dnd-drop-target", "false"); await expect(page.locator("[data-grid-placeholder-for]")).toHaveCount(0); const previewRevision = await targetGrid.getAttribute("data-dnd-preview-revision"); expect(previewRevision).not.toBeNull(); await page.waitForTimeout(250); await expect(targetGrid).toHaveAttribute("data-dnd-drop-valid", "false"); await expect(targetGrid).toHaveAttribute("data-dnd-preview-revision", previewRevision ?? ""); await expect(page.locator("[data-grid-placeholder-for]")).toHaveCount(0); await page.mouse.up(); await expect(page.locator("body")).not.toHaveAttribute("data-board-grid-interacting"); await expect(locator).not.toHaveAttribute("data-dnd-drag-source"); await expect(targetGrid).toHaveAttribute("data-dnd-drop-target", "false"); await expect.poll(async () => await readEditorGridSnapshotAsync(sourceGrid)).toEqual(snapshot); }; const getGridEntryLocatorAsync = async (locator: Locator) => { const entry = (await locator.getAttribute("data-grid-item-id")) ? locator : locator.locator("xpath=ancestor::*[@data-grid-item-id][1]"); await expect(entry).toHaveCount(1); return entry; }; const dragLocatorToAsync = async (page: Page, locator: Locator, targetX: number, targetY: number) => { const entry = await getGridEntryLocatorAsync(locator); await expectGridEntryGeometrySettledAsync(entry); 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 }); if (await locator.getAttribute("data-grid-resize-handle")) { await expect(page.locator("body")).toHaveAttribute("data-board-grid-interacting", "resize"); } else { const itemId = await entry.getAttribute("data-grid-item-id"); expect(itemId).not.toBeNull(); if (!itemId) throw new Error("Expected draggable grid entry id"); await expect(entry).toHaveAttribute("data-dnd-drag-source", "true"); await expect(page.locator(`[data-grid-placeholder-for="${itemId}"]`)).toHaveCount(1); } await page.mouse.up(); await expect(page.locator("body")).not.toHaveAttribute("data-board-grid-interacting"); }; const dragLocatorByTouchAsync = async (page: Page, locator: Locator, deltaX: number, deltaY: number) => { const box = await expectBoundingBoxAsync(locator); const start = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; const session = await page.context().newCDPSession(page); await session.send("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 1 }); try { await session.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ ...start, id: 1, radiusX: 1, radiusY: 1, force: 1 }], }); await page.waitForTimeout(225); for (let step = 1; step <= 12; step += 1) { await session.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [ { x: start.x + (deltaX * step) / 12, y: start.y + (deltaY * step) / 12, id: 1, radiusX: 1, radiusY: 1, force: 1, }, ], }); } await expect(locator).toHaveAttribute("data-dnd-drag-source", "true"); await session.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); await expect(locator).not.toHaveAttribute("data-dnd-drag-source"); } finally { await session.send("Emulation.setTouchEmulationEnabled", { enabled: false }); await session.detach(); } }; const expectTouchResizeCancellationRestoresAsync = async ( page: Page, grid: Locator, handle: Locator, deltaX: number, deltaY: number, ) => { const snapshot = await readEditorGridSnapshotAsync(grid); const box = await expectBoundingBoxAsync(handle); const start = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; const session = await page.context().newCDPSession(page); await session.send("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 1 }); try { await session.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ ...start, id: 1, radiusX: 1, radiusY: 1, force: 1 }], }); await expect(page.locator("body")).toHaveAttribute("data-board-grid-interacting", "resize"); for (let step = 1; step <= 4; step += 1) { await session.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [ { x: start.x + (deltaX * step) / 4, y: start.y + (deltaY * step) / 4, id: 1, radiusX: 1, radiusY: 1, force: 1, }, ], }); } await session.send("Input.dispatchTouchEvent", { type: "touchCancel", touchPoints: [] }); await expect(page.locator("body")).not.toHaveAttribute("data-board-grid-interacting"); await expect.poll(async () => await readEditorGridSnapshotAsync(grid)).toEqual(snapshot); } finally { await session.send("Emulation.setTouchEmulationEnabled", { enabled: false }); await session.detach(); } }; const dragLocatorByImmediateReleaseAsync = async ( page: Page, locator: Locator, deltaX: number, deltaY: number, afterActivation?: () => Promise, ) => { const box = await expectBoundingBoxAsync(locator); await dragLocatorToImmediateReleaseAsync( page, locator, box.x + box.width * 0.63 + deltaX, box.y + box.height * 0.42 + deltaY, afterActivation, ); }; const dragLocatorToImmediateReleaseAsync = async ( page: Page, locator: Locator, targetX: number, targetY: number, afterActivation?: () => Promise, ) => { const box = await expectBoundingBoxAsync(locator); const start = { x: box.x + box.width * 0.63, y: box.y + box.height * 0.42 }; await page.mouse.move(start.x, start.y); await page.mouse.down(); await page.mouse.move(start.x + 8, start.y); await expect(locator).toHaveAttribute("data-dnd-drag-source", "true"); await afterActivation?.(); await page.evaluate( ({ x, y }) => { document.dispatchEvent( new PointerEvent("pointermove", { bubbles: true, pointerId: 1, pointerType: "mouse", isPrimary: true, button: -1, buttons: 1, clientX: x, clientY: y, }), ); document.dispatchEvent( new PointerEvent("pointerup", { bubbles: true, pointerId: 1, pointerType: "mouse", isPrimary: true, button: 0, buttons: 0, clientX: x, clientY: y, }), ); }, { x: targetX, y: targetY }, ); await page.mouse.up(); await expect(locator).not.toHaveAttribute("data-dnd-drag-source"); await expect(page.locator("body")).not.toHaveAttribute("data-board-grid-interacting"); }; const resizeLocatorByImmediateReleaseAsync = async (page: Page, handle: Locator, deltaX: number, deltaY: number) => { const entry = await getGridEntryLocatorAsync(handle); await expectGridEntryGeometrySettledAsync(entry); const box = await expectBoundingBoxAsync(handle); const start = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; await page.mouse.move(start.x, start.y); await page.mouse.down(); await expect(page.locator("body")).toHaveAttribute("data-board-grid-interacting", "resize"); await handle.evaluate( (element, { x, y }) => { element.dispatchEvent( new PointerEvent("pointerup", { bubbles: true, pointerId: 1, pointerType: "mouse", isPrimary: true, button: 0, buttons: 0, clientX: x, clientY: y, }), ); }, { x: start.x + deltaX, y: start.y + deltaY }, ); await page.mouse.up(); await expect(page.locator("body")).not.toHaveAttribute("data-board-grid-interacting"); }; 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 getKeyboardEditorEntry = (entry: Locator) => entry.locator("[data-editor-grid-entry]").first(); const readEditorGridSnapshotAsync = async (grid: Locator) => await grid.locator(":scope > .board-grid-entry").evaluateAll((entries) => entries .map((entry) => ({ id: entry.getAttribute("data-grid-item-id"), x: entry.getAttribute("data-grid-x"), y: entry.getAttribute("data-grid-y"), w: entry.getAttribute("data-grid-w"), h: entry.getAttribute("data-grid-h"), })) .toSorted((first, second) => (first.id ?? "").localeCompare(second.id ?? "")), ); 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 expectDocumentNotHorizontallyScrollableAsync = async (page: Page) => { await expect .poll( async () => await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth), ) .toBeLessThanOrEqual(1); }; 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;