merge: stack assistant on mobile boards
This commit is contained in:
1326 files changed
+131355
-46380
No files matched your search
@@ -0,0 +1,505 @@
|
||||
import type { Browser, BrowserContext, Page } from "@playwright/test";
|
||||
import { chromium, expect } from "@playwright/test";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { stringify } from "superjson";
|
||||
import { afterAll, beforeAll, describe, test } from "vitest";
|
||||
|
||||
import { hashPasswordAsync } from "../packages/auth/security";
|
||||
import {
|
||||
apps,
|
||||
boardUserPermissions,
|
||||
boards,
|
||||
itemLayouts,
|
||||
items,
|
||||
layouts,
|
||||
sections,
|
||||
users,
|
||||
} from "../packages/db/schema/sqlite";
|
||||
import type { SqliteDatabase } from "./shared/e2e-db";
|
||||
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
||||
import { createHomarrContainer } from "./shared/create-homarr-container";
|
||||
import { loginAsync } from "./shared/login";
|
||||
import { seedAdminUserAsync } from "./shared/seed-admin-user";
|
||||
|
||||
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";
|
||||
|
||||
describe("Board advanced interactions", () => {
|
||||
let browser: Browser;
|
||||
let baseUrl: string;
|
||||
let stopContainerAsync: (() => Promise<void>) | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
const { userId: ownerId } = await seedAdminUserAsync(db, ownerCredentials);
|
||||
await seedInteractionBoardAsync(db, ownerId);
|
||||
await seedCredentialsUserAsync(db, {
|
||||
...disabledModifierCredentials,
|
||||
enableRightClickOnWidgets: false,
|
||||
boardPermission: "modify",
|
||||
});
|
||||
await seedCredentialsUserAsync(db, {
|
||||
...viewerCredentials,
|
||||
enableRightClickOnWidgets: true,
|
||||
boardPermission: "view",
|
||||
});
|
||||
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
environment: { AUTH_PROVIDERS: "credentials" },
|
||||
mounts: { "/appdata": localMountPath },
|
||||
}).start();
|
||||
stopContainerAsync = async () => {
|
||||
await homarrContainer.stop();
|
||||
};
|
||||
baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
||||
browser = await chromium.launch();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
await stopContainerAsync?.();
|
||||
});
|
||||
|
||||
test("opens advanced view from Shift-hover, keyboard, and the widget context menu", async () => {
|
||||
const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials);
|
||||
|
||||
try {
|
||||
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("[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]");
|
||||
|
||||
await compactSurface.evaluate((element) => {
|
||||
element.setAttribute("data-lifecycle-probe", "same-instance");
|
||||
});
|
||||
await widget.focus();
|
||||
await page.keyboard.down("Shift");
|
||||
await widget.hover();
|
||||
await expect(previewSurface).toBeVisible({ timeout: 2_000 });
|
||||
await expect(previewSurface).toHaveCSS("animation-name", "none");
|
||||
const compactBounds = await widget.boundingBox();
|
||||
const advancedBounds = await previewSurface.boundingBox();
|
||||
if (!compactBounds || !advancedBounds) throw new Error("Advanced widget has no bounds");
|
||||
expect(advancedBounds?.width).toBeGreaterThan(compactBounds?.width ?? 0);
|
||||
await expect(previewSurface).toHaveAttribute("data-lifecycle-probe", "same-instance");
|
||||
await expect(dimmingOverlay).toBeVisible();
|
||||
await expect(dimmingOverlay).toHaveCSS("pointer-events", "none");
|
||||
await expect(otherWidget).toBeVisible();
|
||||
await expect(widget).toBeFocused();
|
||||
await expect(header).not.toHaveAttribute("inert", "");
|
||||
await expect(boardMain).not.toHaveAttribute("inert", "");
|
||||
|
||||
await previewSurface.click({ button: "right", position: { x: 80, y: 80 } });
|
||||
const portalledMenuItem = page.getByRole("menuitem", { name: "Open advanced view" });
|
||||
await expect(portalledMenuItem).toBeVisible();
|
||||
await portalledMenuItem.hover();
|
||||
await expect(previewSurface).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(portalledMenuItem).toBeHidden();
|
||||
await expect(previewSurface).toBeVisible();
|
||||
|
||||
const otherBounds = await otherWidget.boundingBox();
|
||||
expect(otherBounds).not.toBeNull();
|
||||
if (!otherBounds) throw new Error("Bookmarks widget has no bounds");
|
||||
await previewSurface.evaluate(
|
||||
(element, position) => {
|
||||
const probe = document.createElement("button");
|
||||
probe.dataset.interactivePreviewProbe = "true";
|
||||
probe.style.cssText = `position:absolute;z-index:20;width:40px;height:40px;left:${position.left}px;top:${position.top}px`;
|
||||
element.append(probe);
|
||||
},
|
||||
{
|
||||
left: otherBounds.x + otherBounds.width / 2 - advancedBounds.x - 20,
|
||||
top: otherBounds.y + otherBounds.height / 2 - advancedBounds.y - 20,
|
||||
},
|
||||
);
|
||||
const interactivePreviewProbe = previewSurface.locator("[data-interactive-preview-probe='true']");
|
||||
await interactivePreviewProbe.hover();
|
||||
await expect(previewSurface).toBeVisible();
|
||||
await expect(bookmarksPreviewSurface).toBeHidden();
|
||||
await interactivePreviewProbe.evaluate((element) => element.remove());
|
||||
await page.mouse.move(compactBounds.x + 4, compactBounds.y + 4);
|
||||
await page.mouse.move(otherBounds.x + otherBounds.width / 2, otherBounds.y + otherBounds.height / 2);
|
||||
await expect(previewSurface).toBeHidden();
|
||||
await expect(bookmarksPreviewSurface).toBeVisible({ timeout: 2_000 });
|
||||
await page.keyboard.up("Shift");
|
||||
await expect(bookmarksPreviewSurface).toBeHidden();
|
||||
await expect(dimmingOverlay).toBeHidden();
|
||||
await expect(compactSurface).toHaveAttribute("data-lifecycle-probe", "same-instance");
|
||||
await expect(widget).toBeFocused();
|
||||
|
||||
await page.keyboard.press("Shift+Enter");
|
||||
await expect(manualSurface).toBeVisible();
|
||||
await expect(manualSurface).toHaveAttribute("aria-modal", "true");
|
||||
await expect(header).toHaveAttribute("inert", "");
|
||||
await expect(boardMain).toHaveAttribute("inert", "");
|
||||
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");
|
||||
await expect(page.getByRole("button", { name: "Close advanced view" })).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.getByRole("button", { name: "Close advanced view" })).toBeFocused();
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect(page.getByRole("button", { name: "Close advanced view" })).toBeFocused();
|
||||
await expect(page.getByRole("button", { name: /advanced view is pinned|keep advanced view open/i })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await manualSurface.evaluate((element) => {
|
||||
const mainViewport = document.createElement("div");
|
||||
mainViewport.dataset.scrollbars = "y";
|
||||
mainViewport.style.cssText = "height:80px;overflow-y:auto";
|
||||
|
||||
const probe = document.createElement("div");
|
||||
probe.dataset.shiftScrollProbe = "true";
|
||||
probe.style.cssText = "height:80px;overflow-y:auto";
|
||||
probe.innerHTML = '<svg data-shift-scroll-target="true"></svg><div style="height:600px"></div>';
|
||||
probe.addEventListener("wheel", (event) => event.stopPropagation());
|
||||
mainViewport.append(probe);
|
||||
mainViewport.insertAdjacentHTML("beforeend", '<div style="height:600px"></div>');
|
||||
element.append(mainViewport);
|
||||
|
||||
const unrelated = document.createElement("div");
|
||||
unrelated.dataset.shiftScrollProbe = "unrelated";
|
||||
unrelated.style.cssText = "height:80px;overflow-y:hidden";
|
||||
unrelated.innerHTML = '<div style="height:600px"></div>';
|
||||
element.append(unrelated);
|
||||
});
|
||||
const scrollProbe = manualSurface.locator("[data-shift-scroll-probe='true']");
|
||||
await page.keyboard.down("Shift");
|
||||
const pixelEventCancelled = await scrollProbe.evaluate(
|
||||
(element) =>
|
||||
!element.dispatchEvent(
|
||||
new WheelEvent("wheel", { bubbles: true, cancelable: true, shiftKey: true, deltaX: 120 }),
|
||||
),
|
||||
);
|
||||
expect(pixelEventCancelled).toBe(true);
|
||||
await scrollProbe.evaluate((element) => {
|
||||
element.scrollTop = 0;
|
||||
});
|
||||
const lineEventCancelled = await scrollProbe
|
||||
.locator("[data-shift-scroll-target='true']")
|
||||
.evaluate(
|
||||
(element) =>
|
||||
!element.dispatchEvent(
|
||||
new WheelEvent("wheel", { bubbles: true, cancelable: true, shiftKey: true, deltaX: 3, deltaMode: 1 }),
|
||||
),
|
||||
);
|
||||
await page.keyboard.up("Shift");
|
||||
expect(lineEventCancelled).toBe(true);
|
||||
expect(await scrollProbe.evaluate((element) => element.scrollTop)).toBeGreaterThanOrEqual(48);
|
||||
await scrollProbe.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
element.dispatchEvent(
|
||||
new WheelEvent("wheel", { bubbles: true, cancelable: true, shiftKey: true, deltaX: 120 }),
|
||||
);
|
||||
});
|
||||
expect(await manualSurface.locator("[data-scrollbars='y']").evaluate((element) => element.scrollTop)).toBe(120);
|
||||
expect(
|
||||
await manualSurface.locator("[data-shift-scroll-probe='unrelated']").evaluate((element) => element.scrollTop),
|
||||
).toBe(0);
|
||||
|
||||
await manualSurface.click({ button: "right", position: { x: 80, y: 80 } });
|
||||
await expect(portalledMenuItem).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(portalledMenuItem).toBeHidden();
|
||||
await expect(manualSurface).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(manualSurface).toBeHidden();
|
||||
await expect(widget).toBeFocused();
|
||||
await expect(header).not.toHaveAttribute("inert", "");
|
||||
await expect(boardMain).not.toHaveAttribute("inert", "");
|
||||
expect(await page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe("hidden");
|
||||
|
||||
await widget.click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Open advanced view" }).click();
|
||||
await expect(manualSurface).toBeVisible();
|
||||
const viewport = page.viewportSize();
|
||||
if (!viewport) throw new Error("Board page has no viewport");
|
||||
await page.mouse.click(4, viewport.height - 4);
|
||||
await expect(manualSurface).toBeHidden();
|
||||
await expect(widget).toBeFocused();
|
||||
|
||||
for (const compactOnlyKind of ["app", "iframe"]) {
|
||||
const compactOnlyWidget = page
|
||||
.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 });
|
||||
await expect(page.getByRole("menuitem", { name: "Open advanced view" })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await page.keyboard.down("Shift");
|
||||
await compactOnlyWidget.hover({ position: { x: 4, y: 4 } });
|
||||
await expect(previewSurface).toBeHidden();
|
||||
await expect(dimmingOverlay).toBeHidden();
|
||||
await page.keyboard.up("Shift");
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("keeps compact-only iframe state and dismisses retained focus when a responsive layout remounts", async () => {
|
||||
const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials);
|
||||
|
||||
try {
|
||||
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>';
|
||||
(element as HTMLIFrameElement).dataset.advancedFocusProbe = "true";
|
||||
});
|
||||
const frame = page.frameLocator("iframe[data-advanced-focus-probe='true']");
|
||||
await frame.locator("#state").fill("changed");
|
||||
|
||||
await expect(iframeSlot).not.toHaveAttribute("aria-keyshortcuts", "Shift+Enter");
|
||||
await iframeSlot.dispatchEvent("contextmenu", { button: 2 });
|
||||
await expect(page.getByRole("menuitem", { name: "Open advanced view" })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await page.keyboard.down("Shift");
|
||||
await iframeSlot.hover({ position: { x: 4, y: 4 } });
|
||||
await expect(page.getByRole("region", { name: "iFrame advanced view" })).toHaveCount(0);
|
||||
await page.keyboard.up("Shift");
|
||||
await expect(frame.locator("#state")).toHaveValue("changed");
|
||||
|
||||
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("[data-grid-item-id][data-kind='iframe'] iframe")).toBeVisible();
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("keeps advanced controls below the fixed header on short screens", async () => {
|
||||
const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials, { width: 1366, height: 768 });
|
||||
|
||||
try {
|
||||
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" });
|
||||
const bounds = await closeButton.boundingBox();
|
||||
expect(bounds).not.toBeNull();
|
||||
if (!bounds) return;
|
||||
expect(bounds.x).toBeGreaterThanOrEqual(0);
|
||||
expect(bounds.y).toBeGreaterThanOrEqual(60);
|
||||
expect(bounds.width).toBeGreaterThanOrEqual(44);
|
||||
expect(bounds.height).toBeGreaterThanOrEqual(44);
|
||||
expect(bounds.x + bounds.width).toBeLessThanOrEqual(1366);
|
||||
expect(bounds.y + bounds.height).toBeLessThanOrEqual(768);
|
||||
const surfaceBounds = await page.getByRole("dialog", { name: "Date and time advanced view" }).boundingBox();
|
||||
const contentBounds = await page.locator(".clock-widget-container").boundingBox();
|
||||
expect(surfaceBounds).not.toBeNull();
|
||||
expect(contentBounds).not.toBeNull();
|
||||
if (surfaceBounds && contentBounds) {
|
||||
expect(contentBounds.y - surfaceBounds.y).toBeLessThan(32);
|
||||
expect(bounds.x >= surfaceBounds.x + surfaceBounds.width || bounds.x + bounds.width <= surfaceBounds.x).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
await closeButton.click();
|
||||
await expect(page.getByRole("dialog", { name: "Date and time advanced view" })).toBeHidden();
|
||||
} 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("[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);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("keeps read-only advanced viewing", async () => {
|
||||
const { context, page } = await openBoardAsync(browser, baseUrl, viewerCredentials);
|
||||
|
||||
try {
|
||||
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");
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
const openBoardAsync = async (
|
||||
browser: Browser,
|
||||
baseUrl: string,
|
||||
credentials: { username: string; password: string },
|
||||
viewport = { width: 1280, height: 900 },
|
||||
): Promise<{ context: BrowserContext; page: Page }> => {
|
||||
const context = await browser.newContext({ reducedMotion: "reduce", viewport });
|
||||
const page = await context.newPage();
|
||||
|
||||
await loginAsync({ page, baseUrl, credentials, destination: `/boards/${boardName}` });
|
||||
await expect(page.locator(".clock-wrapper").first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
return { context, page };
|
||||
};
|
||||
|
||||
const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) => {
|
||||
const boardId = createId();
|
||||
const baseLayoutId = createId();
|
||||
const desktopLayoutId = createId();
|
||||
const sectionId = createId();
|
||||
const clockItemId = createId();
|
||||
const bookmarksItemId = createId();
|
||||
const iframeItemId = createId();
|
||||
const appItemId = createId();
|
||||
const appId = createId();
|
||||
|
||||
await db.insert(boards).values({ id: boardId, name: boardName, creatorId: ownerId, isPublic: false });
|
||||
await db.insert(apps).values({
|
||||
id: appId,
|
||||
name: "Example app",
|
||||
iconUrl: "/favicon.ico",
|
||||
href: "https://example.com",
|
||||
});
|
||||
await db.insert(layouts).values([
|
||||
{ id: baseLayoutId, name: "Base", columnCount: 12, breakpoint: 0, boardId },
|
||||
{ id: desktopLayoutId, name: "Desktop", columnCount: 12, breakpoint: 1000, boardId },
|
||||
]);
|
||||
await db.insert(sections).values({ id: sectionId, kind: "empty", xOffset: 0, yOffset: 0, boardId });
|
||||
await db.insert(items).values([
|
||||
{
|
||||
id: clockItemId,
|
||||
kind: "clock",
|
||||
boardId,
|
||||
options: stringify({ is24HourFormat: true, showSeconds: false, showDate: true }),
|
||||
advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }),
|
||||
},
|
||||
{
|
||||
id: bookmarksItemId,
|
||||
kind: "bookmarks",
|
||||
boardId,
|
||||
options: stringify({
|
||||
title: "",
|
||||
layout: "column",
|
||||
hideTitle: false,
|
||||
hideIcon: false,
|
||||
hideHostname: false,
|
||||
openNewTab: true,
|
||||
withBorder: false,
|
||||
items: [appId],
|
||||
}),
|
||||
advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }),
|
||||
},
|
||||
{
|
||||
id: iframeItemId,
|
||||
kind: "iframe",
|
||||
boardId,
|
||||
options: stringify({ embedUrl: "http://127.0.0.1:9", allowScrolling: true }),
|
||||
advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }),
|
||||
},
|
||||
{
|
||||
id: appItemId,
|
||||
kind: "app",
|
||||
boardId,
|
||||
options: stringify({ appId, openInNewTab: true, showTitle: true, pingEnabled: false }),
|
||||
advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }),
|
||||
},
|
||||
]);
|
||||
await db.insert(itemLayouts).values(
|
||||
[baseLayoutId, desktopLayoutId].flatMap((layoutId) => [
|
||||
{
|
||||
itemId: clockItemId,
|
||||
sectionId,
|
||||
layoutId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 4,
|
||||
height: 3,
|
||||
},
|
||||
{
|
||||
itemId: bookmarksItemId,
|
||||
sectionId,
|
||||
layoutId,
|
||||
xOffset: 0,
|
||||
yOffset: 3,
|
||||
width: 4,
|
||||
height: 3,
|
||||
},
|
||||
{
|
||||
itemId: iframeItemId,
|
||||
sectionId,
|
||||
layoutId,
|
||||
xOffset: 8,
|
||||
yOffset: 0,
|
||||
width: 4,
|
||||
height: 3,
|
||||
},
|
||||
{
|
||||
itemId: appItemId,
|
||||
sectionId,
|
||||
layoutId,
|
||||
xOffset: 4,
|
||||
yOffset: 0,
|
||||
width: 3,
|
||||
height: 3,
|
||||
},
|
||||
]),
|
||||
);
|
||||
await db
|
||||
.update(users)
|
||||
.set({ homeBoardId: boardId, completedBoardTour: true, completedManageTour: true })
|
||||
.where(eq(users.id, ownerId));
|
||||
};
|
||||
|
||||
const seedCredentialsUserAsync = async (
|
||||
db: SqliteDatabase,
|
||||
input: {
|
||||
username: string;
|
||||
password: string;
|
||||
enableRightClickOnWidgets: boolean;
|
||||
boardPermission: "view" | "modify";
|
||||
},
|
||||
) => {
|
||||
const userId = createId();
|
||||
const board = await db.query.boards.findFirst({ where: eq(boards.name, boardName) });
|
||||
if (!board) throw new Error("Interaction board was not seeded");
|
||||
|
||||
await db.insert(users).values({
|
||||
id: userId,
|
||||
name: input.username,
|
||||
provider: "credentials",
|
||||
password: await hashPasswordAsync(input.password),
|
||||
enableRightClickOnWidgets: input.enableRightClickOnWidgets,
|
||||
homeBoardId: board.id,
|
||||
completedBoardTour: true,
|
||||
completedManageTour: true,
|
||||
});
|
||||
await db.insert(boardUserPermissions).values({
|
||||
boardId: board.id,
|
||||
userId,
|
||||
permission: input.boardPermission,
|
||||
});
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
});
|
||||
@@ -1,87 +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 { 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("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 context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.goto(`${baseUrl}/auth/login`);
|
||||
// The submit button is disabled until the form finishes hydrating, so wait for it
|
||||
// before filling to avoid a hydration re-mount resetting the field values.
|
||||
const submitButton = page.locator("css=button[type='submit']");
|
||||
await expect(submitButton).toBeEnabled({ timeout: 30_000 });
|
||||
await page.getByLabel("Username").fill(adminCredentials.username);
|
||||
await page.locator("#password").fill(adminCredentials.password);
|
||||
await expect(page.getByLabel("Username")).toHaveValue(adminCredentials.username);
|
||||
await submitButton.click();
|
||||
await expect(page).not.toHaveURL(/\/auth\/login/, { timeout: 30_000 });
|
||||
|
||||
await page.goto(`${baseUrl}/manage/custom-widgets/new`);
|
||||
await page.waitForURL("**/manage/custom-widgets/new", { timeout: 15_000 });
|
||||
await page.getByRole("textbox", { name: "Name" }).fill("E2E Custom JSX");
|
||||
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,
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Create" }).first().click();
|
||||
await expect(page.getByText('Widget "E2E Custom JSX" created successfully.')).toBeVisible({ timeout: 15_000 });
|
||||
await page.waitForURL("**/manage/custom-widgets/edit/**", { 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 "E2E Custom JSX" updated successfully.')).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByLabel("JSX Template")).toHaveValue(updatedTemplate);
|
||||
} finally {
|
||||
await browser.close();
|
||||
await homarrContainer.stop();
|
||||
await mockApi.close();
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
+33
-20
@@ -4,22 +4,30 @@ import { createHomarrContainer } from "./shared/create-homarr-container";
|
||||
import { createRedisContainer } from "./shared/redis-container";
|
||||
|
||||
describe("Health checks", () => {
|
||||
test("ready and live should return 200 OK with normal image and no extra configuration", async () => {
|
||||
test("ready and live should return 200 OK with normal image and no extra configuration", async ({
|
||||
onTestFinished,
|
||||
}) => {
|
||||
// Arrange
|
||||
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 () => {
|
||||
test("ready and live should return 200 OK with external redis", async ({ onTestFinished }) => {
|
||||
// Arrange
|
||||
const redisContainer = await createRedisContainer().start();
|
||||
onTestFinished(() => redisContainer.stop());
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
environment: {
|
||||
REDIS_IS_EXTERNAL: "true",
|
||||
@@ -28,19 +36,24 @@ describe("Health checks", () => {
|
||||
REDIS_PASSWORD: redisContainer.getPassword(),
|
||||
},
|
||||
}).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
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
interface LoginInput {
|
||||
page: Page;
|
||||
baseUrl: string;
|
||||
credentials: {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
destination?: string;
|
||||
}
|
||||
|
||||
export const loginAsync = async ({ page, baseUrl, credentials, destination = "/" }: LoginInput) => {
|
||||
const destinationUrl = new URL(destination, baseUrl);
|
||||
const loginUrl = new URL("/auth/login", baseUrl);
|
||||
loginUrl.searchParams.set("callbackUrl", `${destinationUrl.pathname}${destinationUrl.search}${destinationUrl.hash}`);
|
||||
|
||||
await page.goto(loginUrl.href, { waitUntil: "load" });
|
||||
await page.waitForFunction(() => {
|
||||
const form = document.querySelector("form");
|
||||
return form !== null && Object.keys(form).some((key) => key.startsWith("__reactProps$"));
|
||||
});
|
||||
await page.getByLabel("Username").fill(credentials.username);
|
||||
await page.locator("#password").fill(credentials.password);
|
||||
|
||||
const destinationNavigation = page.waitForURL(
|
||||
(url) =>
|
||||
url.origin === destinationUrl.origin &&
|
||||
url.pathname === destinationUrl.pathname &&
|
||||
url.search === destinationUrl.search &&
|
||||
url.hash === destinationUrl.hash,
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await page.locator("button[type='submit']").click();
|
||||
await destinationNavigation;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user