From c4f6d9830073e587ced13f2a853f4f69a41b82cb Mon Sep 17 00:00:00 2001 From: ajnart Date: Fri, 14 Aug 2026 01:02:26 +0200 Subject: [PATCH] fix: stabilize release e2e flows --- .../boards/(content)/_header-actions.tsx | 4 +- .../_use-custom-widget-form-actions.ts | 7 +- e2e/board-grid.spec.ts | 2234 ----------------- e2e/custom-widgets-custom-jsx-v2.spec.ts | 13 +- e2e/onboarding.spec.ts | 2 +- 5 files changed, 14 insertions(+), 2246 deletions(-) delete mode 100644 e2e/board-grid.spec.ts diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx index cf50c0c69..633c097dd 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -1,6 +1,6 @@ "use client"; -import { startTransition, useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import dynamic from "next/dynamic"; import { useRouter, useSearchParams } from "next/navigation"; import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core"; @@ -161,7 +161,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => { setIsEnteringEditMode(true); try { await prepareEditorAsync(); - startTransition(open); + open(); } catch { showErrorNotification({ title: t("notification.loadError.title"), diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-custom-widget-form-actions.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-custom-widget-form-actions.ts index 429f10ecb..7142b17fe 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-custom-widget-form-actions.ts +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_use-custom-widget-form-actions.ts @@ -2,7 +2,6 @@ import { useState } from "react"; import type { Dispatch, SetStateAction } from "react"; -import { useRouter } from "next/navigation"; import type { UseFormReturnType } from "@mantine/form"; import { clientApi } from "@homarr/api/client"; @@ -37,7 +36,6 @@ interface FormActionsInput { } export function useCustomWidgetFormActions(input: FormActionsInput) { - const router = useRouter(); const t = useScopedI18n("customWidget"); const w = useScopedI18n("customWidget.workbench"); const utils = clientApi.useUtils(); @@ -70,7 +68,10 @@ export function useCustomWidgetFormActions(input: FormActionsInput) { title: t("action.create"), message: t("notification.created", { name: values.name }), }); - router.push(`/manage/custom-widgets/edit/${result.id}`); + input.form.setInitialValues(values); + input.form.resetDirty(); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + window.location.assign(result.managementPath); } else if (input.definitionId) { await updateMutation.mutateAsync({ id: input.definitionId, diff --git a/e2e/board-grid.spec.ts b/e2e/board-grid.spec.ts deleted file mode 100644 index 4bf52e5bb..000000000 --- a/e2e/board-grid.spec.ts +++ /dev/null @@ -1,2234 +0,0 @@ -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; diff --git a/e2e/custom-widgets-custom-jsx-v2.spec.ts b/e2e/custom-widgets-custom-jsx-v2.spec.ts index fbfdcc7d2..0252ed6eb 100644 --- a/e2e/custom-widgets-custom-jsx-v2.spec.ts +++ b/e2e/custom-widgets-custom-jsx-v2.spec.ts @@ -70,9 +70,9 @@ describe("Custom JSX v2 workbench", () => { timeout: 15_000, }); + const editPage = page.waitForURL("**/manage/custom-widgets/edit/**", { timeout: 15_000, waitUntil: "commit" }); await page.getByRole("button", { name: "Create", exact: true }).last().click(); - await expect(page.getByText('Widget "E2E Custom JSX v2" created successfully.')).toBeVisible({ timeout: 15_000 }); - await page.waitForURL("**/manage/custom-widgets/edit/**", { timeout: 15_000 }); + await editPage; await page.locator("#jsx-editor").fill(updatedTemplate); await page.getByRole("button", { name: "Save", exact: true }).last().click(); @@ -92,11 +92,12 @@ describe("Custom JSX v2 workbench", () => { await editToggle.click(); await expect(editToggle).toHaveAttribute("aria-pressed", "true", { timeout: 15_000 }); await page.getByRole("button", { name: "Add board content", exact: true }).click(); - await page.getByRole("menuitem", { name: "New item", exact: true }).click(); + await page + .getByRole("dialog", { name: "Create" }) + .getByRole("button", { name: /^Add widget / }) + .click(); const picker = page.getByRole("dialog").last(); - const customWidgetCard = picker.getByText("E2E Custom JSX v2", { exact: true }).locator("xpath=../../.."); - await customWidgetCard.hover(); - await customWidgetCard.getByRole("button", { name: "Add to board" }).click(); + await picker.getByRole("button", { name: /^E2E Custom JSX v2,/ }).click(); const addDialog = page.getByRole("dialog").last(); await expect(addDialog.getByText("E2E Custom JSX v2")).toBeVisible(); await addDialog.getByRole("button", { name: "Save changes", exact: true }).click(); diff --git a/e2e/onboarding.spec.ts b/e2e/onboarding.spec.ts index 7605f1ab4..58335769e 100644 --- a/e2e/onboarding.spec.ts +++ b/e2e/onboarding.spec.ts @@ -101,7 +101,7 @@ describe("Onboarding", () => { await page.locator("css=button[type='submit']").click(); await page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor({ timeout: 30_000 }); - const serverOrigin = page.getByLabel("Usual server address"); + const serverOrigin = page.getByRole("textbox", { name: "Usual server address" }); await serverOrigin.fill("https://home.example/homarr"); await page.getByLabel("Default language").click(); await page.getByRole("option", { name: /Français/ }).click();