test: add mobile layout E2E tests for iPhone 15

- Create tests/e2e/mobile/mobile-layout.spec.ts with 8 tests covering
  AppBar/hamburger visibility, drawer open/close, mobile card rendering,
  PageHeader button layout, dialog width, card actions, and analytics overflow.
- Fix AnalyticsClient: make Autocomplete full-width on mobile, add
  overflow:hidden to outer Stack to prevent body scrollWidth growth.
- Fix WorldMapInner: remove hard-coded minWidth:400 that caused 73px
  horizontal overflow on 393px iPhone 15 viewport.
- Fix DashboardLayoutClient: add overflowX:hidden to main content area
  to contain chart library elements that exceed viewport width.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
fuomag9
2026-03-12 09:04:58 +01:00
parent bfcc24eac0
commit 6e8db4ec39
4 changed files with 122 additions and 4 deletions

View File

@@ -229,7 +229,8 @@ export default function DashboardLayoutClient({ user, children }: { user: User;
flexGrow: 1,
p: { xs: 2, md: 5 },
width: { md: `calc(100% - ${DRAWER_WIDTH}px)` },
mt: { xs: `${APP_BAR_HEIGHT}px`, md: 0 }
mt: { xs: `${APP_BAR_HEIGHT}px`, md: 0 },
overflowX: 'hidden',
}}
>
<Box sx={{ maxWidth: 1200, mx: "auto" }}>

View File

@@ -282,7 +282,7 @@ export default function AnalyticsClient() {
// ── Render ────────────────────────────────────────────────────────────────
return (
<Stack spacing={4}>
<Stack spacing={4} sx={{ maxWidth: '100%', overflow: 'hidden' }}>
{/* Header */}
<Stack direction={{ xs: 'column', sm: 'row' }} alignItems={{ sm: 'center' }} justifyContent="space-between" spacing={2}>
<Box>
@@ -356,7 +356,7 @@ export default function AnalyticsClient() {
onChange={(_e, v) => setSelectedHosts(v)}
disableCloseOnSelect
limitTags={2}
sx={{ width: 260, flexShrink: 0 }}
sx={{ width: { xs: '100%', sm: 260 }, flexShrink: 0 }}
ListboxProps={{
// Prevent scroll from the dropdown list leaking to the page
style: { overscrollBehavior: 'contain' },

View File

@@ -289,7 +289,8 @@ export default function WorldMapInner({ data, selectedCountry }: { data: Country
border: '1px solid rgba(148,163,184,0.08)',
flex: 1,
minHeight: 280,
minWidth: 400,
minWidth: { xs: 0, sm: 400 },
width: '100%',
}}>
<MapGL
mapStyle={MAP_STYLE}

View File

@@ -0,0 +1,116 @@
import { test, expect } from '@playwright/test';
// All tests in this file are intended for the mobile-iphone project.
// They rely on the iPhone 15 viewport (393x852) set in playwright.config.ts.
// Skip this entire describe block when running on non-mobile projects (e.g. chromium desktop).
// The mobile-iphone project uses WebKit (iPhone 15) so we detect by viewport width.
test.describe('Mobile layout', () => {
test.beforeEach(async ({ page }) => {
// Skip on desktop viewports — these tests are mobile-only
const viewport = page.viewportSize();
if (!viewport || viewport.width > 600) {
test.skip();
}
});
test('app bar is visible with hamburger and title', async ({ page }) => {
await page.goto('/');
// The MUI AppBar should be present on mobile
const appBar = page.locator('header');
await expect(appBar).toBeVisible();
// Hamburger button
await expect(page.getByRole('button', { name: /open drawer/i })).toBeVisible();
// Title text
await expect(page.getByText('Caddy Proxy Manager')).toBeVisible();
});
test('drawer opens and closes via hamburger', async ({ page }) => {
await page.goto('/');
// Drawer is closed initially — it renders as a dialog with keepMounted
// but the dialog should not be visible (no active attr on closed drawer)
// Open drawer first to get a reference to the dialog
const drawerDialog = page.locator('[role="dialog"]');
// The dialog is hidden (not visible) before opening
await expect(drawerDialog.getByRole('link', { name: /proxy hosts/i })).not.toBeVisible();
// Open drawer
await page.getByRole('button', { name: /open drawer/i }).click();
await expect(drawerDialog.getByRole('link', { name: /proxy hosts/i })).toBeVisible();
// Close by pressing Escape
await page.keyboard.press('Escape');
await expect(drawerDialog.getByRole('link', { name: /proxy hosts/i })).not.toBeVisible();
});
test('navigating from drawer closes it', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /open drawer/i }).click();
const drawerDialog = page.locator('[role="dialog"]');
const drawerNavLink = drawerDialog.getByRole('link', { name: /proxy hosts/i });
await expect(drawerNavLink).toBeVisible();
// Click a nav link inside the drawer
await drawerNavLink.click();
await expect(page).toHaveURL('/proxy-hosts');
// Drawer should close after navigation — drawer links no longer visible
await expect(drawerDialog.getByRole('link', { name: /access lists/i })).not.toBeVisible();
});
test('proxy hosts page shows card list, not a table', async ({ page }) => {
await page.goto('/proxy-hosts');
// On mobile with mobileCard, there should be no <table> element
// (DataTable renders cards instead)
await expect(page.locator('table')).not.toBeVisible();
});
test('page header action button appears below title on mobile', async ({ page }) => {
await page.goto('/proxy-hosts');
const title = page.getByRole('heading', { name: /proxy hosts/i });
const button = page.getByRole('button', { name: /create host/i });
await expect(title).toBeVisible();
await expect(button).toBeVisible();
// Button should be below the title — its Y coordinate should be greater
const titleBox = await title.boundingBox();
const buttonBox = await button.boundingBox();
expect(titleBox).not.toBeNull();
expect(buttonBox).not.toBeNull();
expect(buttonBox!.y).toBeGreaterThan(titleBox!.y + titleBox!.height - 1);
});
test('create host dialog is usable at mobile width', async ({ page }) => {
await page.goto('/proxy-hosts');
await page.getByRole('button', { name: /create host/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
// Dialog should not overflow — check it fits in viewport
const dialogBox = await dialog.boundingBox();
const viewportWidth = page.viewportSize()?.width ?? 393;
expect(dialogBox).not.toBeNull();
expect(dialogBox!.width).toBeLessThanOrEqual(viewportWidth + 1); // +1 for rounding
// Key form fields should be visible
await expect(page.getByLabel(/domains/i)).toBeVisible();
});
test('card edit and delete actions reachable without scrolling', async ({ page }) => {
await page.goto('/proxy-hosts');
// Create a host so there is at least one card to inspect
await page.getByRole('button', { name: /create host/i }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByLabel('Name').fill('Mobile Test Host');
await page.getByLabel(/domains/i).fill('mobile-test.local');
await page.getByPlaceholder('10.0.0.5:8080').fill('localhost:9999');
await page.getByRole('button', { name: /^create$/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10_000 });
// The mobileCard renderer must include Edit and Delete icon buttons with aria-labels.
// They should be immediately visible — no horizontal scroll needed.
await expect(page.getByRole('button', { name: /^edit$/i }).first()).toBeVisible();
await expect(page.getByRole('button', { name: /^delete$/i }).first()).toBeVisible();
});
test('analytics page loads without horizontal body overflow', async ({ page }) => {
await page.goto('/analytics');
// Wait for content to load
await page.waitForLoadState('networkidle');
// The document body should not be wider than the viewport
const bodyWidth = await page.evaluate(() => document.body.scrollWidth);
const viewportWidth = page.viewportSize()?.width ?? 393;
expect(bodyWidth).toBeLessThanOrEqual(viewportWidth + 5); // 5px tolerance
});
});