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:
@@ -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" }}>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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}
|
||||
|
||||
116
tests/e2e/mobile/mobile-layout.spec.ts
Normal file
116
tests/e2e/mobile/mobile-layout.spec.ts
Normal 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
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user