Files
homarr/e2e/board-grid.spec.ts
T

2235 lines
98 KiB
TypeScript

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<ReturnType<typeof chromium.launch>> | undefined;
let runtimeResources: ReturnType<typeof trackRuntimeResources> | 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<HTMLElement>(`[data-grid-placeholder-for="${itemId}"]`);
const targetGrid = placeholder?.closest<HTMLElement>("[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<Promise<void>>();
const inFlightRequests = new Set<Request>();
const requestPhases = new WeakMap<Request, RuntimeResourcePhase>();
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<ReturnType<Locator["boundingBox"]>> = 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<number, IdleRequestCallback>();
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<void>,
) => {
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<void>,
) => {
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<void>((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;