import { chromium, devices, expect } from "@playwright/test"; import { createId } from "@paralleldrive/cuid2"; import SuperJSON from "superjson"; import { describe, test } from "vitest"; import { eq } from "@homarr/db"; import * as sqliteSchema from "@homarr/db/schema/sqlite"; import { createHomarrContainer } from "./shared/create-homarr-container"; import { createSqliteDbFileAsync } from "./shared/e2e-db"; import type { SqliteDatabase } from "./shared/e2e-db"; import { seedAdminUserAsync } from "./shared/seed-admin-user"; const adminCredentials = { username: "admin", password: "Comp(exP4sswOrd", }; const boardName = "mobile-e2e"; const seedMobileBoardAsync = async (db: SqliteDatabase, userId: string) => { const boardId = createId(); const layoutId = createId(); const firstSectionId = createId(); const secondSectionId = createId(); await db.insert(sqliteSchema.boards).values({ id: boardId, name: boardName, creatorId: userId, }); await db.insert(sqliteSchema.layouts).values({ id: layoutId, name: "Desktop", boardId, columnCount: 12, breakpoint: 0, }); await db.insert(sqliteSchema.sections).values([ { id: firstSectionId, boardId, kind: "category", name: "First section", xOffset: 0, yOffset: 0, }, { id: secondSectionId, boardId, kind: "category", name: "Second section", xOffset: 0, yOffset: 20, }, ]); const itemRows = Array.from({ length: 36 }, (_, index) => { const id = createId(); return { id, boardId, kind: index === 0 ? ("notebook" as const) : ("clock" as const), options: SuperJSON.stringify(index === 0 ? { content: "
Mobile notes
" } : {}), advancedOptions: SuperJSON.stringify({}), }; }); await db.insert(sqliteSchema.items).values(itemRows); await db.insert(sqliteSchema.itemLayouts).values( itemRows.map((item, index) => ({ itemId: item.id, layoutId, sectionId: index < 18 ? firstSectionId : secondSectionId, xOffset: (index % 6) * 2, yOffset: Math.floor((index % 18) / 6) * 2, 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("keeps phones and narrow tablets read-only, two-column, accessible, and overflow-free", async () => { const { db, localMountPath } = await createSqliteDbFileAsync(); const { userId } = await seedAdminUserAsync(db, adminCredentials); await seedMobileBoardAsync(db, 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 loginContext = await browser.newContext(); try { const loginPage = await loginContext.newPage(); await loginPage.goto(`${baseUrl}/auth/login`); await loginPage.getByLabel("Username").fill(adminCredentials.username); await loginPage.locator("#password").fill(adminCredentials.password); await loginPage.locator("button[type='submit']").click(); await loginPage.waitForURL((url) => !url.pathname.includes("/auth/login"), { timeout: 15_000 }); const storageState = await loginContext.storageState(); const mobileViewports = [ { name: "small phone", width: 320, height: 568, userAgent: devices["iPhone 13"].userAgent, phone: true }, { name: "phone", width: 390, height: 844, userAgent: devices["iPhone 13"].userAgent, phone: true }, { name: "large phone", width: 430, height: 932, userAgent: devices["iPhone 13"].userAgent, phone: true }, { name: "phone landscape", width: 844, height: 390, userAgent: devices["iPhone 13"].userAgent, phone: true, }, { name: "narrow tablet", width: 768, height: 1024, userAgent: devices["iPad (gen 7)"].userAgent, phone: false, }, ]; for (const viewport of mobileViewports) { const context = await browser.newContext({ storageState, viewport: { width: viewport.width, height: viewport.height }, userAgent: viewport.userAgent, hasTouch: true, isMobile: viewport.phone, reducedMotion: viewport.width === 390 ? "reduce" : "no-preference", }); const page = await context.newPage(); await page.goto(`${baseUrl}/boards/${boardName}`); await expect(page.locator("[data-mobile-board]"), viewport.name).toBeVisible({ timeout: 15_000 }); await expect(page.locator("[data-mobile-board-item]"), viewport.name).toHaveCount(36); await expect(page.locator(".grid-stack-item"), viewport.name).toHaveCount(0); await expect(page.getByRole("button", { name: /search/i }), viewport.name).toBeVisible(); const layoutMetrics = await page.evaluate(() => { const grids = Array.from(document.querySelectorAll