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

979 lines
39 KiB
TypeScript

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