import { chromium, devices, expect } from "@playwright/test"; import { createId } from "@paralleldrive/cuid2"; import { parse, stringify } from "superjson"; import { describe, test } from "vitest"; import { eq } from "drizzle-orm"; 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 credentials = { username: "admin", password: "Comp(exP4sswOrd", }; const boardName = "mobile-e2e"; const seedBoardAsync = async (db: SqliteDatabase, userId: string) => { const boardId = createId(); const desktopLayoutId = createId(); const mobileLayoutId = createId(); const sectionId = createId(); const emptySectionId = createId(); const notebookId = createId(); const clockId = createId(); await db.insert(sqliteSchema.boards).values({ id: boardId, name: boardName, creatorId: userId, }); await db.insert(sqliteSchema.layouts).values([ { id: desktopLayoutId, name: "Desktop", boardId, columnCount: 12, breakpoint: 1200, }, { id: mobileLayoutId, name: "Mobile", boardId, columnCount: 4, breakpoint: 0, }, ]); await db.insert(sqliteSchema.sections).values([ { id: sectionId, boardId, kind: "category", name: "Dashboard", xOffset: 0, yOffset: 0, }, { id: emptySectionId, boardId, kind: "empty", xOffset: 0, yOffset: 1, }, ]); await db.insert(sqliteSchema.items).values([ { id: notebookId, boardId, kind: "notebook", options: stringify({ content: "
Normal notebook content
" }), advancedOptions: stringify({}), }, { id: clockId, boardId, kind: "clock", options: stringify({}), advancedOptions: stringify({}), }, ]); await db.insert(sqliteSchema.itemLayouts).values([ { itemId: notebookId, layoutId: desktopLayoutId, sectionId, xOffset: 0, yOffset: 0, width: 2, height: 2, }, { itemId: clockId, layoutId: desktopLayoutId, sectionId, xOffset: 2, yOffset: 0, width: 1, height: 1, }, { itemId: notebookId, layoutId: mobileLayoutId, sectionId, xOffset: 0, yOffset: 0, width: 4, height: 4, }, { itemId: clockId, layoutId: mobileLayoutId, sectionId, xOffset: 0, yOffset: 4, width: 2, height: 2, }, ]); await db .update(sqliteSchema.users) .set({ homeBoardId: boardId, mobileHomeBoardId: boardId, completedBoardTour: true, }) .where(eq(sqliteSchema.users.id, userId)); }; describe("Automatic mobile board", () => { test("enables automatic mobile layout for a fresh database", async () => { const { db, localMountPath } = await createSqliteDbFileAsync(); const container = await createHomarrContainer({ mounts: { "/appdata": localMountPath, }, }).start(); try { const boardSettings = await db.query.serverSettings.findFirst({ where: eq(sqliteSchema.serverSettings.settingKey, "board"), }); if (!boardSettings) throw new Error("Expected board server settings to be seeded"); expect(parse<{ enableAutomaticMobileLayout: boolean }>(boardSettings.value).enableAutomaticMobileLayout).toBe( true, ); } finally { await container.stop(); } }, 90_000); test("squeezes unchanged widgets into a read-only two-column grid", async () => { const { db, localMountPath } = await createSqliteDbFileAsync(); const { userId } = await seedAdminUserAsync(db, credentials); await seedBoardAsync(db, userId); await db.insert(sqliteSchema.serverSettings).values({ settingKey: "board", value: stringify({ homeBoardId: null, mobileHomeBoardId: null, enableAutomaticMobileLayout: true, enableStatusByDefault: true, forceDisableStatus: false, }), }); const container = await createHomarrContainer({ environment: { AUTH_PROVIDERS: "credentials", }, mounts: { "/appdata": localMountPath, }, }).start(); const baseUrl = `http://${container.getHost()}:${container.getMappedPort(7575)}`; const browser = await chromium.launch(); const loginContext = await browser.newContext(); try { const loginPage = await loginContext.newPage(); await loginPage.goto(`${baseUrl}/auth/login`); await loginPage.getByLabel("Username").fill(credentials.username); await loginPage.locator("#password").fill(credentials.password); await loginPage.locator("button[type='submit']").click(); await loginPage.waitForURL((url) => !url.pathname.includes("/auth/login"), { timeout: 15_000 }); await loginPage.goto(`${baseUrl}/boards/${boardName}`); await expect(loginPage.locator("[data-mobile-board-preview-toggle]")).toHaveCount(0); await loginPage.getByRole("button", { name: "Toggle board edit mode", exact: true }).click(); const previewToggle = loginPage.getByRole("button", { name: "Mobile preview", exact: true }); await expect(previewToggle).toBeVisible(); await previewToggle.click(); await expect(loginPage.locator("[data-mobile-board-preview]")).toBeVisible(); await expect(loginPage.locator("[data-mobile-board-preview-item]")).toHaveCount(2); await loginPage .locator("header button") .filter({ has: loginPage.locator("svg.tabler-icon-plus") }) .click(); await loginPage.getByRole("menuitem", { name: "New item", exact: true }).click(); await loginPage.getByPlaceholder("Filter items...").fill("Weather"); await loginPage.getByPlaceholder("Filter items...").press("Enter"); await expect(loginPage.locator("[data-mobile-board-preview-item]")).toHaveCount(3); await loginPage.getByRole("button", { name: "Cancel", exact: true }).click(); await loginPage.getByRole("button", { name: "Collapse mobile preview", exact: true }).click(); await expect(loginPage.locator("[data-mobile-board-preview]")).toHaveCount(0); await expect(previewToggle).toBeVisible(); const storageState = await loginContext.storageState(); const mobileContext = await browser.newContext({ storageState, viewport: { width: 390, height: 844 }, userAgent: devices["iPhone 13"].userAgent, hasTouch: true, isMobile: true, }); const mobilePage = await mobileContext.newPage(); await mobilePage.goto(`${baseUrl}/boards/${boardName}`); const mobileBoard = mobilePage.locator("[data-mobile-board]"); const items = mobilePage.locator("[data-mobile-board-item]"); await expect(mobileBoard).toBeVisible({ timeout: 15_000 }); await expect(mobilePage.locator("[data-mobile-board-preview-toggle]")).toHaveCount(0); await expect(items).toHaveCount(2); await expect(mobilePage.locator(".grid-stack-item")).toHaveCount(0); await expect(mobilePage.getByText("Normal notebook content")).toBeVisible(); await expect(mobilePage.getByRole("button", { name: "Toggle board edit mode", exact: true })).toHaveCount(0); await expect(mobilePage.getByRole("button", { name: /open widget details/i })).toHaveCount(0); await expect(mobilePage.getByRole("button", { name: /actions for/i })).toHaveCount(0); await expect(mobilePage.locator("[data-mobile-generic-summary], [data-mobile-display-mode]")).toHaveCount(0); const metrics = await mobilePage.evaluate(() => { const grid = document.querySelector