merge: stack widget modernization on dnd kit

This commit is contained in:
Thomas Camlong committed 2026-08-08 20:40:26 +02:00
commit 9edeba5cd2
940 files changed
+104475 -33649

No files matched your search

+16 -77
View File
@@ -1,4 +1,4 @@
import type { Browser, BrowserContext, Locator, Page } from "@playwright/test";
import type { Browser, BrowserContext, Page } from "@playwright/test";
import { chromium, expect } from "@playwright/test";
import { createId } from "@paralleldrive/cuid2";
import { eq } from "drizzle-orm";
@@ -26,7 +26,6 @@ const ownerCredentials = { username: "owner", password: "Comp(exP4sswOrd" };
const disabledModifierCredentials = { username: "modifier", password: "Comp(exP4sswOrd" };
const viewerCredentials = { username: "viewer", password: "Comp(exP4sswOrd" };
const boardName = "advanced-interactions-e2e";
const emptyCellCenterXRatio = 7.5 / 12;
describe("Board advanced interactions", () => {
let browser: Browser;
@@ -68,13 +67,15 @@ describe("Board advanced interactions", () => {
const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials);
try {
const widget = page.locator(".grid-stack-item[data-kind='clock'] > .grid-stack-item-content").first();
const widget = page.locator("[data-grid-item-id][data-kind='clock'] [data-advanced-focus-source]").first();
const compactSurface = page.locator(".clock-wrapper").first();
const previewSurface = page.getByRole("region", { name: "Date and time advanced view" });
const manualSurface = page.getByRole("dialog", { name: "Date and time advanced view" });
const bookmarksPreviewSurface = page.getByRole("region", { name: "Bookmarks advanced view" });
const dimmingOverlay = page.locator("[data-advanced-focus-overlay]");
const otherWidget = page.locator(".grid-stack-item[data-kind='bookmarks'] > .grid-stack-item-content").first();
const otherWidget = page
.locator("[data-grid-item-id][data-kind='bookmarks'] [data-advanced-focus-source]")
.first();
const header = page.locator("header[data-advanced-focus-background]");
const boardMain = page.locator("main[data-advanced-focus-background]");
@@ -142,7 +143,7 @@ describe("Board advanced interactions", () => {
await expect(manualSurface).toHaveAttribute("aria-modal", "true");
await expect(header).toHaveAttribute("inert", "");
await expect(boardMain).toHaveAttribute("inert", "");
expect(await manualSurface.evaluate((element) => element.closest(".grid-stack-item"))).toBeNull();
expect(await manualSurface.evaluate((element) => element.closest("[data-grid-item-id]"))).toBeNull();
await expect(manualSurface.locator(".clock-wrapper")).toHaveAttribute("data-lifecycle-probe", "same-instance");
await expect(dimmingOverlay).toHaveCSS("pointer-events", "auto");
expect(await page.evaluate(() => getComputedStyle(document.body).overflow)).toBe("hidden");
@@ -231,7 +232,7 @@ describe("Board advanced interactions", () => {
for (const compactOnlyKind of ["app", "iframe"]) {
const compactOnlyWidget = page
.locator(`.grid-stack-item[data-kind='${compactOnlyKind}'] > .grid-stack-item-content`)
.locator(`[data-grid-item-id][data-kind='${compactOnlyKind}'] [data-advanced-focus-source]`)
.first();
await expect(compactOnlyWidget).not.toHaveAttribute("aria-keyshortcuts", "Shift+Enter");
await compactOnlyWidget.dispatchEvent("contextmenu", { button: 2 });
@@ -252,7 +253,7 @@ describe("Board advanced interactions", () => {
const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials);
try {
const iframeSlot = page.locator(".grid-stack-item[data-kind='iframe'] > .grid-stack-item-content").first();
const iframeSlot = page.locator("[data-grid-item-id][data-kind='iframe'] [data-advanced-focus-source]").first();
const iframe = iframeSlot.locator("iframe");
await iframe.evaluate((element) => {
(element as HTMLIFrameElement).srcdoc = '<label>State <input id="state" value="initial"></label>';
@@ -271,13 +272,15 @@ describe("Board advanced interactions", () => {
await page.keyboard.up("Shift");
await expect(frame.locator("#state")).toHaveValue("changed");
const bookmarksSlot = page.locator(".grid-stack-item[data-kind='bookmarks'] > .grid-stack-item-content").first();
const bookmarksSlot = page
.locator("[data-grid-item-id][data-kind='bookmarks'] [data-advanced-focus-source]")
.first();
await bookmarksSlot.focus();
await page.keyboard.press("Shift+Enter");
await expect(page.getByRole("dialog", { name: "Bookmarks advanced view" })).toBeVisible();
await page.setViewportSize({ width: 800, height: 900 });
await expect(page.getByRole("dialog", { name: "Bookmarks advanced view" })).toBeHidden();
await expect(page.locator(".grid-stack-item[data-kind='iframe'] iframe")).toBeVisible();
await expect(page.locator("[data-grid-item-id][data-kind='iframe'] iframe")).toBeVisible();
} finally {
await context.close();
}
@@ -287,7 +290,7 @@ describe("Board advanced interactions", () => {
const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials, { width: 1366, height: 768 });
try {
const widget = page.locator(".grid-stack-item[data-kind='clock'] > .grid-stack-item-content").first();
const widget = page.locator("[data-grid-item-id][data-kind='clock'] [data-advanced-focus-source]").first();
await widget.focus();
await page.keyboard.press("Shift+Enter");
const closeButton = page.getByRole("button", { name: "Close advanced view" });
@@ -317,70 +320,29 @@ describe("Board advanced interactions", () => {
}
}, 60_000);
test("opens the add-at-position chooser only when right click and change access are enabled", async () => {
const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials);
try {
const grid = page.locator(".grid-stack[data-kind='empty']");
const addHereMenuItem = page.getByRole("menuitem", { name: "Add item here" });
await expect(grid).toHaveAttribute("aria-label", "Add item here");
await grid.press("Shift+F10");
await expect(addHereMenuItem).toBeFocused();
await page.keyboard.press("Enter");
await expect(page.getByRole("dialog").filter({ hasText: "Choose item to add" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(grid).toBeFocused();
await touchAndHoldEmptyGridSpaceAsync(grid);
await expect(addHereMenuItem).toBeVisible({ timeout: 1_000 });
await page.keyboard.press("Escape");
await expect(addHereMenuItem).toBeHidden();
await expect(grid).toBeFocused();
await rightClickEmptyGridSpaceAsync(grid);
await expect(addHereMenuItem).toBeVisible();
await addHereMenuItem.click();
await expect(page.getByRole("dialog").filter({ hasText: "Choose item to add" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(grid).toBeFocused();
} finally {
await context.close();
}
}, 60_000);
test("honors the user setting gate even when the user can modify the board", async () => {
const { context, page } = await openBoardAsync(browser, baseUrl, disabledModifierCredentials);
try {
const widget = page.locator(".grid-stack-item[data-kind='clock'] > .grid-stack-item-content").first();
const grid = page.locator(".grid-stack[data-kind='empty']");
const widget = page.locator("[data-grid-item-id][data-kind='clock'] [data-advanced-focus-source]").first();
await widget.click({ button: "right" });
await expect(page.getByRole("menuitem", { name: "Open advanced view" })).toHaveCount(0);
await expect(grid).not.toHaveAttribute("aria-label", "Add item here");
await rightClickEmptyGridSpaceAsync(grid);
await expect(page.getByRole("menuitem", { name: "Add item here" })).toHaveCount(0);
} finally {
await context.close();
}
}, 60_000);
test("keeps read-only advanced viewing but blocks add-at-position without change access", async () => {
test("keeps read-only advanced viewing", async () => {
const { context, page } = await openBoardAsync(browser, baseUrl, viewerCredentials);
try {
const widget = page.locator(".grid-stack-item[data-kind='clock'] > .grid-stack-item-content").first();
const grid = page.locator(".grid-stack[data-kind='empty']");
const widget = page.locator("[data-grid-item-id][data-kind='clock'] [data-advanced-focus-source]").first();
await widget.click({ button: "right" });
await page.getByRole("menuitem", { name: "Open advanced view" }).click();
await expect(page.getByRole("dialog", { name: "Date and time advanced view" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(grid).not.toHaveAttribute("aria-label", "Add item here");
await rightClickEmptyGridSpaceAsync(grid);
await expect(page.getByRole("menuitem", { name: "Add item here" })).toHaveCount(0);
} finally {
await context.close();
}
@@ -404,29 +366,6 @@ const openBoardAsync = async (
return { context, page };
};
const rightClickEmptyGridSpaceAsync = async (grid: Locator) => {
const bounds = await grid.boundingBox();
expect(bounds).not.toBeNull();
if (!bounds) return;
await grid.click({
button: "right",
position: { x: bounds.width * emptyCellCenterXRatio, y: Math.min(24, bounds.height / 2) },
});
};
const touchAndHoldEmptyGridSpaceAsync = async (grid: Locator) => {
const bounds = await grid.boundingBox();
expect(bounds).not.toBeNull();
if (!bounds) return;
const clientX = bounds.x + bounds.width * emptyCellCenterXRatio;
const clientY = bounds.y + Math.min(24, bounds.height / 2);
await grid.dispatchEvent("pointerdown", { pointerType: "touch", clientX, clientY });
await new Promise((resolve) => setTimeout(resolve, 800));
await grid.dispatchEvent("pointerup", { pointerType: "touch", clientX, clientY });
};
const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) => {
const boardId = createId();
const baseLayoutId = createId();
File diff suppressed because it is too large. Load diff
+97
View File
@@ -0,0 +1,97 @@
import { chromium, expect } from "@playwright/test";
import type { Browser } from "@playwright/test";
import type { StartedTestContainer } from "testcontainers";
import { describe, test } from "vitest";
import { createHomarrContainer } from "./shared/create-homarr-container";
import { createSqliteDbFileAsync } from "./shared/e2e-db";
import type { MockApiServer } from "./shared/mock-api-server";
import { startMockApiServerAsync } from "./shared/mock-api-server";
import { seedAdminUserAsync } from "./shared/seed-admin-user";
const adminCredentials = { username: "admin", password: "Comp(exP4sswOrd" };
const initialTemplate =
"<Stack><Title order={4}>{data.status?.title}</Title><Badge>{data.status?.status}</Badge></Stack>";
const updatedTemplate = "<Stack><Text fw={700}>{data.status?.title}</Text><Text>{data.status?.value}</Text></Stack>";
describe("Custom JSX v2 workbench", () => {
test("creates, previews, edits, and immediately adds a widget to a board", async () => {
let mockApi: MockApiServer | undefined;
let homarrContainer: StartedTestContainer | undefined;
let browser: Browser | undefined;
try {
mockApi = await startMockApiServerAsync({ title: "E2E Widget", status: "online", value: 42 });
const { db, localMountPath } = await createSqliteDbFileAsync();
await seedAdminUserAsync(db, adminCredentials);
homarrContainer = await createHomarrContainer({
environment: { AUTH_PROVIDERS: "credentials" },
mounts: { "/appdata": localMountPath },
}).start();
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(`${baseUrl}/auth/login`);
await page.waitForLoadState("networkidle");
await page.getByLabel("Username").fill(adminCredentials.username);
await page.locator("#password").fill(adminCredentials.password);
await page.locator("button[type='submit']").click();
await page.waitForURL(baseUrl, { timeout: 15_000 });
await page.goto(`${baseUrl}/manage/custom-widgets/new`);
await page.locator('input[data-path="name"]:visible').fill("E2E Custom JSX v2");
await page.getByRole("textbox", { name: "Base URL" }).fill(mockApi.url);
await page.getByRole("combobox", { name: "Network access" }).click();
await page.getByRole("option", { name: "private" }).click();
await page.getByRole("group", { name: "status" }).getByRole("textbox", { name: "Path" }).fill("/status");
await page.getByRole("button", { name: "Add option" }).click();
const option = page.getByRole("group", { name: "Option 1" });
await option.getByRole("textbox", { name: "Option name" }).fill("label");
await option.getByRole("textbox", { name: "Default" }).fill("E2E");
const request = page.getByRole("group", { name: "status" });
await request.getByRole("button", { name: "Query, body and behavior" }).click();
await request.getByRole("button", { name: "Add query value" }).click();
await request.getByRole("textbox", { name: "Query key" }).fill("label");
await request.getByRole("combobox", { name: "Value source" }).click();
await page.getByRole("option", { name: "Widget option" }).click();
await request.getByRole("combobox", { name: "Option" }).first().click();
await page.getByRole("option", { name: "label" }).click();
await page.getByRole("button", { name: "Advanced manifest JSON" }).first().click();
await expect(page.locator("#requests-editor")).toContainText('"$option": "label"');
await page.locator("#jsx-editor").fill(initialTemplate);
await page.getByRole("button", { name: "Test and preview" }).last().click();
await expect(page.getByRole("complementary", { name: "Widget preview" }).getByText("E2E Widget")).toBeVisible({
timeout: 15_000,
});
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 page.locator("#jsx-editor").fill(updatedTemplate);
await page.getByRole("button", { name: "Save", exact: true }).last().click();
await expect(page.getByText('Widget "E2E Custom JSX v2" updated successfully.')).toBeVisible({ timeout: 15_000 });
await page.goto(baseUrl);
await page.getByRole("button", { name: "Edit", exact: true }).click();
await page.getByRole("button", { name: "New item", exact: true }).click();
await page.getByRole("menuitem", { name: "New item", exact: true }).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();
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();
await expect(page.getByText("Widget definition not found")).not.toBeVisible();
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByText("E2E Widget")).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("42")).toBeVisible({ timeout: 15_000 });
} finally {
await Promise.allSettled([browser?.close(), homarrContainer?.stop(), mockApi?.close()]);
}
}, 180_000);
});
-94
View File
@@ -1,94 +0,0 @@
import { chromium, expect } from "@playwright/test";
import { describe, test } from "vitest";
import { createHomarrContainer } from "./shared/create-homarr-container";
import { createSqliteDbFileAsync } from "./shared/e2e-db";
import { loginAsync } from "./shared/login";
import { startMockApiServerAsync } from "./shared/mock-api-server";
import { seedAdminUserAsync } from "./shared/seed-admin-user";
const adminCredentials = {
username: "admin",
password: "Comp(exP4sswOrd",
};
const initialTemplate =
'<Stack gap="xs"><Title order={4}>{data.title}</Title><Badge color="blue">{data.status}</Badge></Stack>';
const updatedTemplate = '<Stack gap="xs"><Text>{data.title}</Text><Badge color="green">{data.value}</Badge></Stack>';
describe("Custom JSX custom widgets", () => {
test("reliably creates, previews, and edits a customJsx widget", async () => {
const mockApi = await startMockApiServerAsync({
title: "E2E Widget",
status: "online",
value: 42,
});
const { db, localMountPath } = await createSqliteDbFileAsync();
await seedAdminUserAsync(db, adminCredentials);
const homarrContainer = await createHomarrContainer({
environment: {
AUTH_PROVIDERS: "credentials",
},
mounts: {
"/appdata": localMountPath,
},
}).start();
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
const browser = await chromium.launch();
const destination = "/manage/custom-widgets/new";
try {
for (const attempt of [1, 2, 3]) {
const context = await browser.newContext();
const page = await context.newPage();
const widgetName = `E2E Custom JSX ${attempt}`;
try {
await loginAsync({ page, baseUrl, credentials: adminCredentials, destination });
await expect(page.getByRole("textbox", { name: "Name" })).toBeVisible({ timeout: 30_000 });
await page.getByRole("textbox", { name: "Name" }).fill(widgetName);
await page.getByRole("textbox", { name: "URL", exact: true }).fill(`${mockApi.url}/status`);
await page.getByRole("combobox", { name: "Display Type" }).click();
await page.getByRole("option", { name: "Custom JSX" }).click();
await page.getByLabel("JSX Template").fill(initialTemplate);
await page.getByRole("button", { name: "Test" }).first().click();
const previewPanel = page.locator(".mantine-Card-root").filter({ hasText: "Preview" });
await expect(previewPanel.getByRole("heading", { name: "E2E Widget" }).first()).toBeVisible({
timeout: 15_000,
});
const editNavigation = page.waitForURL((url) => url.pathname.startsWith("/manage/custom-widgets/edit/"), {
timeout: 15_000,
waitUntil: "domcontentloaded",
});
await page.getByRole("button", { name: "Create" }).first().click();
await editNavigation;
await expect(page.getByText(`Widget "${widgetName}" created successfully.`)).toBeVisible({
timeout: 15_000,
});
await expect(page.getByLabel("JSX Template")).toHaveValue(initialTemplate);
await page.getByLabel("JSX Template").fill(updatedTemplate);
await expect(page.getByLabel("JSX Template")).toHaveValue(updatedTemplate);
await page.getByRole("button", { name: "Save" }).first().click();
await expect(page.getByText(`Widget "${widgetName}" updated successfully.`)).toBeVisible({
timeout: 15_000,
});
await expect(page.getByLabel("JSX Template")).toHaveValue(updatedTemplate);
} finally {
await context.close();
}
}
} finally {
await browser.close();
await homarrContainer.stop();
await mockApi.close();
}
}, 180_000);
});
+26 -18
View File
@@ -11,13 +11,17 @@ describe("Health checks", () => {
const homarrContainer = await createHomarrContainer().start();
onTestFinished(() => homarrContainer.stop());
// Act
const readyResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/ready`);
const liveResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/live`);
try {
// Act
const readyResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/ready`);
const liveResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/live`);
// Assert
expect(readyResponse.status).toBe(200);
expect(liveResponse.status).toBe(200);
// Assert
expect(readyResponse.status).toBe(200);
expect(liveResponse.status).toBe(200);
} finally {
await homarrContainer.stop();
}
}, 60_000);
test("ready and live should return 200 OK with external redis", async ({ onTestFinished }) => {
@@ -34,18 +38,22 @@ describe("Health checks", () => {
}).start();
onTestFinished(() => homarrContainer.stop());
// Act
const readyResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/ready`);
const liveResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/live`);
try {
// Act
const readyResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/ready`);
const liveResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/live`);
// Assert
expect(
readyResponse.status,
`Expected ready to return OK statusCode=${readyResponse.status} content=${await readyResponse.text()}`,
).toBe(200);
expect(
liveResponse.status,
`Expected live to return OK statusCode=${liveResponse.status} content=${await liveResponse.text()}`,
).toBe(200);
// Assert
expect(
readyResponse.status,
`Expected ready to return OK statusCode=${readyResponse.status} content=${await readyResponse.text()}`,
).toBe(200);
expect(
liveResponse.status,
`Expected live to return OK statusCode=${liveResponse.status} content=${await liveResponse.text()}`,
).toBe(200);
} finally {
await Promise.all([homarrContainer.stop(), redisContainer.stop()]);
}
}, 60_000);
});
+8 -4
View File
@@ -7,10 +7,14 @@ describe("Home", () => {
// Arrange
const homarrContainer = await createHomarrContainer().start();
// Act
const homeResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/`);
try {
// Act
const homeResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/`);
// Assert
expect(homeResponse.status).toBe(200);
// Assert
expect(homeResponse.status).toBe(200);
} finally {
await homarrContainer.stop();
}
}, 60_000);
});
+110
View File
@@ -0,0 +1,110 @@
import { createId } from "@paralleldrive/cuid2";
import { chromium, expect } from "@playwright/test";
import { eq } from "drizzle-orm";
import { stringify } from "superjson";
import { describe, test } from "vitest";
import * as sqliteSchema from "../packages/db/schema/sqlite";
import { createHomarrContainer } from "./shared/create-homarr-container";
import { createSqliteDbFileAsync } from "./shared/e2e-db";
import { seedAdminUserAsync } from "./shared/seed-admin-user";
const adminCredentials = {
username: "admin",
password: "Comp(exP4sswOrd",
};
describe("lazy widget application graph", () => {
test("renders an authenticated board and preserves first-use UI behavior after reload", async () => {
const { db, localMountPath } = await createSqliteDbFileAsync();
const { userId } = await seedAdminUserAsync(db, adminCredentials);
const boardId = createId();
const layoutId = createId();
const sectionId = createId();
const itemId = createId();
await db.insert(sqliteSchema.boards).values({
id: boardId,
name: "lazy-widget-board",
creatorId: userId,
});
await db.insert(sqliteSchema.layouts).values({
id: layoutId,
name: "Base",
boardId,
columnCount: 12,
});
await db.insert(sqliteSchema.sections).values({
id: sectionId,
boardId,
kind: "empty",
xOffset: 0,
yOffset: 0,
});
await db.insert(sqliteSchema.items).values({
id: itemId,
boardId,
kind: "clock",
options: stringify({ showSeconds: true }),
});
await db.insert(sqliteSchema.itemLayouts).values({
itemId,
sectionId,
layoutId,
xOffset: 0,
yOffset: 0,
width: 4,
height: 2,
});
await db.update(sqliteSchema.users).set({ homeBoardId: boardId }).where(eq(sqliteSchema.users.id, userId));
const homarrContainer = await createHomarrContainer({
environment: {
AUTH_PROVIDERS: "credentials",
},
mounts: {
"/appdata": localMountPath,
},
}).start();
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
const pageErrors: Error[] = [];
page.on("pageerror", (error) => pageErrors.push(error));
try {
await page.goto(`${baseUrl}/auth/login`);
await page.getByLabel("Username").fill(adminCredentials.username);
await page.locator("#password").fill(adminCredentials.password);
await page.locator("css=button[type='submit']").click();
await expect(page.locator("[data-homarr-dev-benchmark-board]")).toBeVisible({ timeout: 30_000 });
await expect(page.locator(`[data-id="${itemId}"] .clock-widget-container`)).toBeVisible({ timeout: 15_000 });
await page.keyboard.press("Control+K");
await expect(page.locator("[data-homarr-dev-benchmark-spotlight]")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.locator("[data-homarr-dev-benchmark-spotlight]")).not.toBeVisible();
await expect
.poll(
async () =>
await page.evaluate(async () => {
const registrations = await navigator.serviceWorker.getRegistrations();
return registrations[0]?.scope ?? null;
}),
{ timeout: 15_000 },
)
.toBe(`${baseUrl}/`);
await page.reload();
await expect(page.locator(`[data-id="${itemId}"] .clock-widget-container`)).toBeVisible({ timeout: 15_000 });
expect(pageErrors).toEqual([]);
} finally {
await browser.close();
await homarrContainer.stop();
}
}, 120_000);
});
+37 -34
View File
@@ -37,45 +37,48 @@ describe("LLDAP authorization", () => {
const context = await browser.newContext();
const page = await context.newPage();
const onboardingActions = new OnboardingActions(page, db);
await onboardingActions.skipOnboardingAsync({
group: defaultCredentials.group,
});
try {
const onboardingActions = new OnboardingActions(page, db);
await onboardingActions.skipOnboardingAsync({
group: defaultCredentials.group,
});
// Act
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/auth/login`);
await page.getByLabel("Username").fill(defaultCredentials.username);
await page.locator("#password").fill(defaultCredentials.password);
await page.locator("css=button[type='submit']").click();
// Act
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
await page.goto(`${baseUrl}/auth/login`);
await page.getByLabel("Username").fill(defaultCredentials.username);
await page.locator("#password").fill(defaultCredentials.password);
await page.locator("css=button[type='submit']").click();
// Assert
await page.waitForURL(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
const users = await db.query.users.findMany({
with: {
groups: {
with: {
group: true,
// Assert
await page.waitForURL(baseUrl, { timeout: 60_000 });
const users = await db.query.users.findMany({
with: {
groups: {
with: {
group: true,
},
},
},
},
});
expect(users).toHaveLength(1);
const user = users[0]!;
expect(user).toEqual(
expect.objectContaining({
name: defaultCredentials.username,
email: defaultCredentials.email,
provider: "ldap",
}),
);
});
expect(users).toHaveLength(1);
const user = users[0];
if (!user) {
throw new Error("Expected LDAP login to create one user");
}
expect(user).toEqual(
expect.objectContaining({
name: defaultCredentials.username,
email: defaultCredentials.email,
provider: "ldap",
}),
);
const groups = user.groups.map((g) => g.group.name);
expect(groups).toContain(defaultCredentials.group);
// Cleanup
await browser.close();
await homarrContainer.stop();
await lldapContainer.stop();
const groups = user.groups.map((g) => g.group.name);
expect(groups).toContain(defaultCredentials.group);
} finally {
await Promise.all([browser.close(), homarrContainer.stop(), lldapContainer.stop()]);
}
}, 120_000);
});
+34 -34
View File
@@ -22,25 +22,25 @@ describe("Onboarding", () => {
const actions = new OnboardingActions(page, db);
const assertions = new OnboardingAssertions(page, db);
// Act
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
await actions.startOnboardingAsync("scratch");
await actions.processUserStepAsync({
username: "admin",
password: "Comp(exP4sswOrd",
confirmPassword: "Comp(exP4sswOrd",
});
await actions.processSettingsStepAsync();
await actions.processIntegrationsStepAsync();
try {
// Act
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
await actions.startOnboardingAsync("scratch");
await actions.processUserStepAsync({
username: "admin",
password: "Comp(exP4sswOrd",
confirmPassword: "Comp(exP4sswOrd",
});
await actions.processSettingsStepAsync();
await actions.processIntegrationsStepAsync();
// Assert
await assertions.assertFinishStepVisibleAsync();
await assertions.assertUserAndAdminGroupInsertedAsync("admin");
await assertions.assertDbOnboardingStepAsync("finish");
// Cleanup
await browser.close();
await homarrContainer.stop();
// Assert
await assertions.assertFinishStepVisibleAsync();
await assertions.assertUserAndAdminGroupInsertedAsync("admin");
await assertions.assertDbOnboardingStepAsync("finish");
} finally {
await Promise.all([browser.close(), homarrContainer.stop()]);
}
}, 60_000);
test("External provider onboarding setup should be successful", async () => {
@@ -66,22 +66,22 @@ describe("Onboarding", () => {
const actions = new OnboardingActions(page, db);
const assertions = new OnboardingAssertions(page, db);
// Act
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
await actions.startOnboardingAsync("scratch");
await actions.processExternalGroupStepAsync({
name: externalGroupName,
});
await actions.processSettingsStepAsync();
await actions.processIntegrationsStepAsync();
try {
// Act
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
await actions.startOnboardingAsync("scratch");
await actions.processExternalGroupStepAsync({
name: externalGroupName,
});
await actions.processSettingsStepAsync();
await actions.processIntegrationsStepAsync();
// Assert
await assertions.assertFinishStepVisibleAsync();
await assertions.assertExternalGroupInsertedAsync(externalGroupName);
await assertions.assertDbOnboardingStepAsync("finish");
// Cleanup
await browser.close();
await homarrContainer.stop();
// Assert
await assertions.assertFinishStepVisibleAsync();
await assertions.assertExternalGroupInsertedAsync(externalGroupName);
await assertions.assertDbOnboardingStepAsync("finish");
} finally {
await Promise.all([browser.close(), homarrContainer.stop()]);
}
}, 60_000);
});
@@ -57,6 +57,6 @@ export class OnboardingAssertions {
}
public async assertFinishStepVisibleAsync() {
await this.page.waitForSelector("text=completed the setup", { timeout: 15000 });
await this.page.waitForSelector("text=completed the setup", { timeout: 30_000 });
}
}
+6 -4
View File
@@ -1,5 +1,5 @@
import { GenericContainer, Wait } from "testcontainers";
import { Environment } from "testcontainers/build/types";
import type { Environment } from "testcontainers/build/types";
export const createHomarrContainer = (
options: {
@@ -10,11 +10,13 @@ export const createHomarrContainer = (
};
} = {},
) => {
if (!process.env.CI) {
throw new Error("This test should only be run in CI or with a homarr image named 'homarr-e2e'");
const configuredImage = process.env.HOMARR_E2E_IMAGE?.trim();
const image = configuredImage || "homarr-e2e";
if (!process.env.CI && !configuredImage) {
throw new Error(`This test should only be run in CI or with a local Homarr image (configured: '${image}')`);
}
const container = new GenericContainer("homarr-e2e")
const container = new GenericContainer(image)
.withExposedPorts(7575)
.withEnvironment({
...options.environment,
+32 -17
View File
@@ -1,35 +1,50 @@
import { createServer } from "node:http";
import { getContainerRuntimeClient, TestContainers } from "testcontainers";
import type { Server } from "node:http";
const TESTCONTAINERS_SSHD_LABEL = "org.testcontainers.sshd";
export interface MockApiServer {
url: string;
port: number;
close: () => Promise<void>;
}
export const startMockApiServerAsync = async (responseBody: unknown): Promise<MockApiServer> => {
export async function startMockApiServerAsync(responseBody: unknown): Promise<MockApiServer> {
const body = JSON.stringify(responseBody);
const server: Server = await new Promise((resolve) => {
const s = createServer((_req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(body);
const candidate = createServer((_request, response) => {
response.writeHead(200, { "content-type": "application/json" });
response.end(body);
});
s.listen(0, "0.0.0.0", () => resolve(s));
candidate.listen(0, "0.0.0.0", () => resolve(candidate));
});
const address = server.address();
if (!address || typeof address === "string") {
server.close();
throw new Error("Failed to start mock API server");
}
return {
port: address.port,
url: `http://host.docker.internal:${address.port}`,
close: () =>
new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
}),
};
};
try {
await TestContainers.exposeHostPorts(address.port);
const client = await getContainerRuntimeClient();
const forwarder = (await client.container.list()).find(
(container) => container.State === "running" && container.Labels[TESTCONTAINERS_SSHD_LABEL] === "true",
);
const forwarderAddress = forwarder
? Object.values(forwarder.NetworkSettings.Networks).find((network) => network.IPAddress)?.IPAddress
: undefined;
if (!forwarderAddress) throw new Error("Failed to locate the Testcontainers host-port forwarder");
return {
url: `http://${forwarderAddress}:${address.port}`,
close: () =>
new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
}),
};
} catch (error) {
server.close();
throw error;
}
}
+2
View File
@@ -36,6 +36,8 @@ export const seedAdminUserAsync = async (db: SqliteDatabase, input: SeedAdminUse
name: input.username,
provider: "credentials",
password: await hashPasswordAsync(input.password),
completedManageTour: true,
completedBoardTour: true,
});
await db.insert(sqliteSchema.groups).values({