mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-13 16:54:48 +00:00
Fix responsive navigation and mobile table layouts
This commit is contained in:
parent
8c8affc041
commit
391d3d0be0
13 changed files with 531 additions and 219 deletions
|
|
@ -1,12 +1,63 @@
|
|||
import { test, expect, devices } from '@playwright/test';
|
||||
import { ensureAuthenticated } from './helpers';
|
||||
|
||||
const getViewportWidth = async (page: import('@playwright/test').Page): Promise<number> => {
|
||||
const getViewportWidth = async (
|
||||
page: import('@playwright/test').Page,
|
||||
): Promise<number> => {
|
||||
const size = page.viewportSize();
|
||||
if (size) return size.width;
|
||||
return await page.evaluate(() => window.innerWidth);
|
||||
};
|
||||
|
||||
const MOBILE_VIEWPORTS = [
|
||||
{ width: 320, height: 568, label: 'compact phone' },
|
||||
{ width: 390, height: 844, label: 'modern phone' },
|
||||
] as const;
|
||||
|
||||
const CANONICAL_MOBILE_SURFACE_ROUTES = [
|
||||
'/',
|
||||
'/preview/setup-complete',
|
||||
'/route-that-does-not-exist',
|
||||
'/proxmox/overview',
|
||||
'/proxmox/storage',
|
||||
'/proxmox/replication',
|
||||
'/proxmox/backups',
|
||||
'/proxmox/ceph',
|
||||
'/proxmox/mail',
|
||||
'/docker/overview',
|
||||
'/docker/images',
|
||||
'/docker/storage',
|
||||
'/docker/networks',
|
||||
'/docker/swarm',
|
||||
'/kubernetes/overview',
|
||||
'/kubernetes/nodes',
|
||||
'/kubernetes/workloads',
|
||||
'/kubernetes/services',
|
||||
'/kubernetes/storage',
|
||||
'/kubernetes/configuration',
|
||||
'/kubernetes/events',
|
||||
'/truenas/overview',
|
||||
'/truenas/storage',
|
||||
'/truenas/services',
|
||||
'/truenas/apps',
|
||||
'/truenas/vms',
|
||||
'/truenas/shares',
|
||||
'/truenas/protection',
|
||||
'/vmware/overview',
|
||||
'/vmware/storage',
|
||||
'/vmware/networks',
|
||||
'/vmware/health',
|
||||
'/vmware/activity',
|
||||
'/standalone/machines',
|
||||
'/standalone/availability',
|
||||
'/alerts/overview',
|
||||
'/alerts/thresholds',
|
||||
'/alerts/notifications',
|
||||
'/alerts/schedule',
|
||||
'/alerts/history',
|
||||
'/patrol',
|
||||
] as const;
|
||||
|
||||
test.describe('Mobile viewport flows', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await ensureAuthenticated(page);
|
||||
|
|
@ -16,9 +67,7 @@ test.describe('Mobile viewport flows', () => {
|
|||
await page.goto('/infrastructure');
|
||||
await expect(page.locator('#root')).toBeVisible();
|
||||
|
||||
const bottomNav = page.locator(
|
||||
'nav.md\\:hidden, nav[class*="md:hidden"], .md\\:hidden nav, [class*="md:hidden"] nav',
|
||||
);
|
||||
const bottomNav = page.getByRole('tablist', { name: 'Mobile navigation' });
|
||||
|
||||
// Wait for the nav to mount before evaluating (evaluateAll does not auto-wait;
|
||||
// WebKit can be slower to render SolidJS components than Chromium).
|
||||
|
|
@ -27,21 +76,29 @@ test.describe('Mobile viewport flows', () => {
|
|||
const visibleCount = await bottomNav.evaluateAll((els) => {
|
||||
const isVisible = (el: Element) => {
|
||||
const style = window.getComputedStyle(el as HTMLElement);
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
|
||||
if (
|
||||
style.display === 'none' ||
|
||||
style.visibility === 'hidden' ||
|
||||
style.opacity === '0'
|
||||
)
|
||||
return false;
|
||||
const rect = (el as HTMLElement).getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
return els.filter(isVisible).length;
|
||||
});
|
||||
|
||||
expect(visibleCount, 'Expected a visible mobile bottom nav (md:hidden)').toBeGreaterThan(0);
|
||||
expect(
|
||||
visibleCount,
|
||||
'Expected a visible mobile bottom nav',
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('MobileNavBar has safe-area padding on nav', async ({ page }) => {
|
||||
await page.goto('/infrastructure');
|
||||
await expect(page.locator('#root')).toBeVisible();
|
||||
|
||||
const nav = page.locator('nav.md\\:hidden, nav[class*="md:hidden"]').first();
|
||||
const nav = page.getByRole('tablist', { name: 'Mobile navigation' });
|
||||
await expect(nav).toBeVisible();
|
||||
|
||||
// Verify the safe-area CSS class is applied to the nav. The computed padding-bottom
|
||||
|
|
@ -49,33 +106,150 @@ test.describe('Mobile viewport flows', () => {
|
|||
const hasSafeClass = await nav.evaluate((el: HTMLElement) =>
|
||||
el.classList.contains('pb-safe'),
|
||||
);
|
||||
expect(hasSafeClass, 'Expected nav to have pb-safe class for safe-area-inset-bottom').toBeTruthy();
|
||||
expect(
|
||||
hasSafeClass,
|
||||
'Expected nav to have pb-safe class for safe-area-inset-bottom',
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Infrastructure filter bar does not overflow horizontally', async ({ page }) => {
|
||||
await page.goto('/infrastructure');
|
||||
test('Platform filter bar does not overflow horizontally', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/proxmox/overview');
|
||||
|
||||
await expect(page.getByTestId('infrastructure-page')).toBeVisible();
|
||||
await expect(page.getByPlaceholder('Search resources, IDs, IPs, or tags...')).toBeVisible();
|
||||
await expect(
|
||||
page.getByPlaceholder(
|
||||
'Search VMs and LXCs by name, VMID, node, or status',
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
// On mobile the full filter controls are hidden behind a toggle; only the
|
||||
// search bar + Filters button row should be visible. Check the overall page
|
||||
// body does not overflow horizontally.
|
||||
const viewportWidth = await getViewportWidth(page);
|
||||
const bodyScrollWidth = await page.evaluate(() => document.body.scrollWidth);
|
||||
expect(bodyScrollWidth, 'Infrastructure page body must not overflow horizontally').toBeLessThanOrEqual(viewportWidth + 1);
|
||||
const bodyScrollWidth = await page.evaluate(
|
||||
() => document.body.scrollWidth,
|
||||
);
|
||||
expect(
|
||||
bodyScrollWidth,
|
||||
'Platform page body must not overflow horizontally',
|
||||
).toBeLessThanOrEqual(viewportWidth + 1);
|
||||
});
|
||||
|
||||
test('Infrastructure table wrapper enables horizontal overflow when needed', async ({ page }) => {
|
||||
await page.goto('/infrastructure');
|
||||
test('utility destinations stay pinned beside the scrollable platform rail', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/proxmox/overview');
|
||||
await expect(
|
||||
page.getByPlaceholder(
|
||||
'Search VMs and LXCs by name, VMID, node, or status',
|
||||
),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await expect(page.getByTestId('infrastructure-page')).toBeVisible();
|
||||
await expect(page.getByPlaceholder('Search resources, IDs, IPs, or tags...')).toBeVisible();
|
||||
const primaryRail = page.locator('[data-mobile-nav-rail="primary"]');
|
||||
const utilityRail = page.locator('[data-mobile-nav-rail="utility"]');
|
||||
await expect(primaryRail).toBeVisible();
|
||||
await expect(utilityRail).toBeVisible();
|
||||
|
||||
const tableWrapper = page.locator('div.overflow-x-auto').filter({ has: page.locator('table') }).first();
|
||||
const railGeometry = await primaryRail.evaluate((element) => ({
|
||||
clientWidth: element.clientWidth,
|
||||
scrollWidth: element.scrollWidth,
|
||||
overflowX: window.getComputedStyle(element).overflowX,
|
||||
}));
|
||||
expect(['auto', 'scroll']).toContain(railGeometry.overflowX);
|
||||
expect(railGeometry.scrollWidth).toBeGreaterThanOrEqual(
|
||||
railGeometry.clientWidth,
|
||||
);
|
||||
|
||||
const viewportWidth = await getViewportWidth(page);
|
||||
for (const tabId of ['alerts', 'ai', 'settings']) {
|
||||
const button = utilityRail.locator(`button[data-tab-id="${tabId}"]`);
|
||||
await expect(button).toBeVisible();
|
||||
const box = await button.boundingBox();
|
||||
expect(
|
||||
box,
|
||||
`${tabId} utility destination should have a layout box`,
|
||||
).toBeTruthy();
|
||||
expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(
|
||||
viewportWidth + 1,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('canonical product routes keep mobile overflow contained and dense tables readable', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(240_000);
|
||||
|
||||
for (const viewport of MOBILE_VIEWPORTS) {
|
||||
await page.setViewportSize(viewport);
|
||||
|
||||
for (const route of CANONICAL_MOBILE_SURFACE_ROUTES) {
|
||||
await page.goto(route, { waitUntil: 'domcontentloaded' });
|
||||
await expect(
|
||||
page.locator('#root'),
|
||||
`${route} should render the app surface on a ${viewport.label}`,
|
||||
).toBeVisible();
|
||||
|
||||
const viewportWidth = await getViewportWidth(page);
|
||||
const layout = await page.evaluate(() => ({
|
||||
bodyWidth: document.body.scrollWidth,
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
}));
|
||||
expect(
|
||||
Math.max(layout.bodyWidth, layout.documentWidth),
|
||||
`${route} must contain horizontal overflow inside the owning control or table shell on a ${viewport.label}`,
|
||||
).toBeLessThanOrEqual(viewportWidth + 1);
|
||||
|
||||
const denseTables = await page
|
||||
.locator('#root div.overflow-x-auto')
|
||||
.filter({ has: page.locator('table') })
|
||||
.evaluateAll((wrappers) =>
|
||||
wrappers
|
||||
.map((wrapper) => {
|
||||
const table = wrapper.querySelector('table');
|
||||
const headerCount =
|
||||
table?.querySelectorAll('thead th').length ?? 0;
|
||||
return {
|
||||
headerCount,
|
||||
tableWidth: table?.scrollWidth ?? 0,
|
||||
wrapperWidth: wrapper.clientWidth,
|
||||
};
|
||||
})
|
||||
.filter((table) => table.headerCount >= 4),
|
||||
);
|
||||
|
||||
for (const table of denseTables) {
|
||||
expect(
|
||||
table.tableWidth,
|
||||
`${route} should scroll dense tables instead of compressing their columns on a ${viewport.label}`,
|
||||
).toBeGreaterThan(table.wrapperWidth);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Platform table wrapper enables horizontal overflow when needed', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/proxmox/overview');
|
||||
|
||||
await expect(
|
||||
page.getByPlaceholder(
|
||||
'Search VMs and LXCs by name, VMID, node, or status',
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
const tableWrapper = page
|
||||
.locator('div.overflow-x-auto')
|
||||
.filter({ has: page.locator('table') })
|
||||
.first();
|
||||
const wrapperVisible = await tableWrapper.isVisible().catch(() => false);
|
||||
if (!wrapperVisible) {
|
||||
test.skip(true, 'No infrastructure table rendered (no resources or table not present)');
|
||||
test.skip(
|
||||
true,
|
||||
'No platform table rendered (no resources or table not present)',
|
||||
);
|
||||
}
|
||||
|
||||
const overflowBehavior = await tableWrapper.evaluate((el) => {
|
||||
|
|
@ -98,32 +272,46 @@ test.describe('Mobile viewport flows', () => {
|
|||
expect(overflowBehavior.tableScrollWidth).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('Tapping a resource row opens the detail drawer', async ({ page }) => {
|
||||
await page.goto('/infrastructure');
|
||||
test('Tapping a resource disclosure opens its mobile detail state', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/proxmox/overview');
|
||||
|
||||
await expect(page.getByTestId('infrastructure-page')).toBeVisible();
|
||||
await expect(page.getByPlaceholder('Search resources, IDs, IPs, or tags...')).toBeVisible();
|
||||
await expect(
|
||||
page.getByPlaceholder(
|
||||
'Search VMs and LXCs by name, VMID, node, or status',
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
const row = page.locator('table tbody tr.cursor-pointer').first();
|
||||
const rowVisible = await row.isVisible().catch(() => false);
|
||||
if (!rowVisible) {
|
||||
const disclosure = page
|
||||
.getByRole('button', { name: /^Expand details for / })
|
||||
.first();
|
||||
const disclosureVisible = await disclosure.isVisible().catch(() => false);
|
||||
if (!disclosureVisible) {
|
||||
test.skip(true, 'No resource rows available to expand');
|
||||
}
|
||||
|
||||
await row.click();
|
||||
const expandedLabel = (
|
||||
await disclosure.getAttribute('aria-label')
|
||||
)?.replace(/^Expand /, 'Collapse ');
|
||||
expect(expandedLabel).toBeTruthy();
|
||||
await disclosure.click();
|
||||
|
||||
// On mobile, the drawer renders as a Portal bottom sheet (not inline in a <td>).
|
||||
// Look for the Discovery tab button anywhere on the page.
|
||||
const discoveryTab = page.getByText('Discovery', { exact: true }).first();
|
||||
await expect(discoveryTab).toBeVisible({ timeout: 10000 });
|
||||
await expect(
|
||||
page.getByRole('button', { name: expandedLabel! }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('Infrastructure landing loads without horizontal overflow at mobile viewport', async ({ page }) => {
|
||||
test('Infrastructure landing loads without horizontal overflow at mobile viewport', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/infrastructure');
|
||||
await expect(page.locator('#root')).toBeVisible();
|
||||
|
||||
const viewportWidth = await getViewportWidth(page);
|
||||
const bodyScrollWidth = await page.evaluate(() => document.body.scrollWidth);
|
||||
const bodyScrollWidth = await page.evaluate(
|
||||
() => document.body.scrollWidth,
|
||||
);
|
||||
expect(bodyScrollWidth).toBeLessThanOrEqual(viewportWidth + 1);
|
||||
});
|
||||
|
||||
|
|
@ -131,10 +319,12 @@ test.describe('Mobile viewport flows', () => {
|
|||
await page.goto('/infrastructure');
|
||||
await expect(page.locator('#root')).toBeVisible();
|
||||
|
||||
const nav = page.locator('nav.md\\:hidden, nav[class*="md:hidden"]').first();
|
||||
const nav = page.getByRole('tablist', { name: 'Mobile navigation' });
|
||||
await expect(nav).toBeVisible();
|
||||
|
||||
const aiButton = page.getByRole('button', { name: 'Expand Pulse Assistant' });
|
||||
const aiButton = page.getByRole('button', {
|
||||
name: /Ask Pulse Assistant about/,
|
||||
});
|
||||
await expect(aiButton).toBeVisible();
|
||||
|
||||
const navBox = await nav.boundingBox();
|
||||
|
|
@ -144,7 +334,9 @@ test.describe('Mobile viewport flows', () => {
|
|||
expect(aiBox, 'Expected AI button bounding box').toBeTruthy();
|
||||
|
||||
const navTop = (navBox as { y: number }).y;
|
||||
const aiBottom = (aiBox as { y: number; height: number }).y + (aiBox as { height: number }).height;
|
||||
const aiBottom =
|
||||
(aiBox as { y: number; height: number }).y +
|
||||
(aiBox as { height: number }).height;
|
||||
expect(aiBottom).toBeLessThanOrEqual(navTop + 1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,150 +6,181 @@ import { createAuthenticatedStorageState } from './helpers';
|
|||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
type WorkerFixtures = {
|
||||
authStorageStatePath: string;
|
||||
authStorageStatePath: string;
|
||||
};
|
||||
|
||||
const SETTINGS_ROUTES = [
|
||||
'/settings/infrastructure',
|
||||
'/settings/infrastructure/pve',
|
||||
'/settings/infrastructure/pbs',
|
||||
'/settings/infrastructure/pmg',
|
||||
'/settings/workloads',
|
||||
'/settings/workloads/docker',
|
||||
'/settings/organization',
|
||||
'/settings/organization/access',
|
||||
'/settings/organization/sharing',
|
||||
'/settings/organization/billing',
|
||||
'/settings/organization/billing-admin',
|
||||
'/settings/integrations/api',
|
||||
'/operations/diagnostics',
|
||||
'/operations/reporting',
|
||||
'/operations/logs',
|
||||
'/settings/system-general',
|
||||
'/settings/system-network',
|
||||
'/settings/system-updates',
|
||||
'/settings/system-recovery',
|
||||
'/settings/system-ai',
|
||||
'/settings/system-relay',
|
||||
'/settings/system-pro',
|
||||
'/settings/security-overview',
|
||||
'/settings/security-auth',
|
||||
'/settings/security-sso',
|
||||
'/settings/security-roles',
|
||||
'/settings/security-users',
|
||||
'/settings/security-audit',
|
||||
'/settings/security-webhooks',
|
||||
'/settings/infrastructure',
|
||||
'/settings/monitoring/availability',
|
||||
'/settings/pulse-intelligence/provider',
|
||||
'/settings/pulse-intelligence/patrol',
|
||||
'/settings/pulse-intelligence/assistant',
|
||||
'/settings/pulse-intelligence/discovery',
|
||||
'/settings/pulse-intelligence/billing/plan',
|
||||
'/settings/pulse-intelligence/billing/usage',
|
||||
'/settings/organization',
|
||||
'/settings/organization/access',
|
||||
'/settings/organization/sharing',
|
||||
'/settings/organization/billing',
|
||||
'/settings/organization/billing-admin',
|
||||
'/settings/system-general',
|
||||
'/settings/system-network',
|
||||
'/settings/system-updates',
|
||||
'/settings/system-recovery',
|
||||
'/settings/system-relay',
|
||||
'/settings/support/diagnostics',
|
||||
'/settings/support/reporting',
|
||||
'/settings/support/logs',
|
||||
'/settings/security/api',
|
||||
'/settings/security-overview',
|
||||
'/settings/security-data-handling',
|
||||
'/settings/security-auth',
|
||||
'/settings/security-sso',
|
||||
'/settings/security-roles',
|
||||
'/settings/security-users',
|
||||
'/settings/security-audit',
|
||||
'/settings/security-webhooks',
|
||||
] as const;
|
||||
|
||||
const MOBILE_VIEWPORTS = [
|
||||
{ width: 320, height: 568, label: 'compact phone' },
|
||||
{ width: 390, height: 844, label: 'modern phone' },
|
||||
] as const;
|
||||
|
||||
const test = base.extend<{}, WorkerFixtures>({
|
||||
storageState: async ({ authStorageStatePath }, use) => {
|
||||
await use(authStorageStatePath);
|
||||
storageState: async ({ authStorageStatePath }, use) => {
|
||||
await use(authStorageStatePath);
|
||||
},
|
||||
authStorageStatePath: [
|
||||
async ({ browser }, use, workerInfo) => {
|
||||
const storageStatePath = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'tmp',
|
||||
'playwright-auth',
|
||||
`settings-mobile-audit-${workerInfo.project.name}.json`,
|
||||
);
|
||||
fs.mkdirSync(path.dirname(storageStatePath), { recursive: true });
|
||||
await createAuthenticatedStorageState(browser, storageStatePath);
|
||||
try {
|
||||
await use(storageStatePath);
|
||||
} finally {
|
||||
fs.rmSync(storageStatePath, { force: true });
|
||||
}
|
||||
},
|
||||
authStorageStatePath: [async ({ browser }, use, workerInfo) => {
|
||||
const storageStatePath = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'tmp',
|
||||
'playwright-auth',
|
||||
`settings-mobile-audit-${workerInfo.project.name}.json`,
|
||||
);
|
||||
fs.mkdirSync(path.dirname(storageStatePath), { recursive: true });
|
||||
await createAuthenticatedStorageState(browser, storageStatePath);
|
||||
try {
|
||||
await use(storageStatePath);
|
||||
} finally {
|
||||
fs.rmSync(storageStatePath, { force: true });
|
||||
}
|
||||
}, { scope: 'worker' }],
|
||||
{ scope: 'worker' },
|
||||
],
|
||||
});
|
||||
|
||||
type OverflowAudit = {
|
||||
viewportWidth: number;
|
||||
pageWidth: number;
|
||||
overflowPx: number;
|
||||
offenders: Array<{ tag: string; className: string; overflow: number }>;
|
||||
viewportWidth: number;
|
||||
pageWidth: number;
|
||||
overflowPx: number;
|
||||
offenders: Array<{ tag: string; className: string; overflow: number }>;
|
||||
};
|
||||
|
||||
const auditHorizontalOverflow = async (
|
||||
page: import('@playwright/test').Page,
|
||||
page: import('@playwright/test').Page,
|
||||
): Promise<OverflowAudit> =>
|
||||
page.evaluate(() => {
|
||||
const viewportWidth = Math.max(document.documentElement.clientWidth, window.innerWidth || 0);
|
||||
const pageWidth = Math.max(
|
||||
document.body.scrollWidth,
|
||||
document.documentElement.scrollWidth,
|
||||
document.body.offsetWidth,
|
||||
document.documentElement.offsetWidth,
|
||||
);
|
||||
|
||||
const offenders = Array.from(document.querySelectorAll('body *'))
|
||||
.map((el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) return null;
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.position === 'fixed' || style.position === 'absolute') return null;
|
||||
const overflow = rect.right - viewportWidth;
|
||||
if (overflow <= 1) return null;
|
||||
return {
|
||||
tag: el.tagName.toLowerCase(),
|
||||
className: (el.getAttribute('class') || '').trim().slice(0, 120),
|
||||
overflow: Number(overflow.toFixed(1)),
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is { tag: string; className: string; overflow: number } => Boolean(entry))
|
||||
.slice(0, 8);
|
||||
page.evaluate(() => {
|
||||
const viewportWidth = Math.max(
|
||||
document.documentElement.clientWidth,
|
||||
window.innerWidth || 0,
|
||||
);
|
||||
const pageWidth = Math.max(
|
||||
document.body.scrollWidth,
|
||||
document.documentElement.scrollWidth,
|
||||
document.body.offsetWidth,
|
||||
document.documentElement.offsetWidth,
|
||||
);
|
||||
|
||||
const offenders = Array.from(document.querySelectorAll('body *'))
|
||||
.map((el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) return null;
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.position === 'fixed' || style.position === 'absolute')
|
||||
return null;
|
||||
const overflow = rect.right - viewportWidth;
|
||||
if (overflow <= 1) return null;
|
||||
return {
|
||||
viewportWidth,
|
||||
pageWidth,
|
||||
overflowPx: Number((pageWidth - viewportWidth).toFixed(1)),
|
||||
offenders,
|
||||
tag: el.tagName.toLowerCase(),
|
||||
className: (el.getAttribute('class') || '').trim().slice(0, 120),
|
||||
overflow: Number(overflow.toFixed(1)),
|
||||
};
|
||||
});
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
entry,
|
||||
): entry is { tag: string; className: string; overflow: number } =>
|
||||
Boolean(entry),
|
||||
)
|
||||
.slice(0, 8);
|
||||
|
||||
const scrollToBottom = async (page: import('@playwright/test').Page): Promise<void> => {
|
||||
const viewportHeight = await page.evaluate(() => window.innerHeight || 800);
|
||||
const step = Math.max(240, Math.floor(viewportHeight * 0.75));
|
||||
let wheelSupported = true;
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
if (wheelSupported) {
|
||||
try {
|
||||
await page.mouse.wheel(0, step);
|
||||
} catch {
|
||||
wheelSupported = false;
|
||||
await page.evaluate((deltaY) => window.scrollBy(0, deltaY), step);
|
||||
}
|
||||
} else {
|
||||
await page.evaluate((deltaY) => window.scrollBy(0, deltaY), step);
|
||||
}
|
||||
await page.waitForTimeout(60);
|
||||
return {
|
||||
viewportWidth,
|
||||
pageWidth,
|
||||
overflowPx: Number((pageWidth - viewportWidth).toFixed(1)),
|
||||
offenders,
|
||||
};
|
||||
});
|
||||
|
||||
const scrollToBottom = async (
|
||||
page: import('@playwright/test').Page,
|
||||
): Promise<void> => {
|
||||
const viewportHeight = await page.evaluate(() => window.innerHeight || 800);
|
||||
const step = Math.max(240, Math.floor(viewportHeight * 0.75));
|
||||
let wheelSupported = true;
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
if (wheelSupported) {
|
||||
try {
|
||||
await page.mouse.wheel(0, step);
|
||||
} catch {
|
||||
wheelSupported = false;
|
||||
await page.evaluate((deltaY) => window.scrollBy(0, deltaY), step);
|
||||
}
|
||||
} else {
|
||||
await page.evaluate((deltaY) => window.scrollBy(0, deltaY), step);
|
||||
}
|
||||
await page.waitForTimeout(60);
|
||||
}
|
||||
};
|
||||
|
||||
test.describe('Settings mobile optimization audit', () => {
|
||||
test.setTimeout(180_000);
|
||||
test.setTimeout(180_000);
|
||||
|
||||
for (const route of SETTINGS_ROUTES) {
|
||||
test(`no horizontal overflow after full scroll on ${route}`, async ({ page }) => {
|
||||
await page.goto(route, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForURL(/\/(settings|operations)/, { timeout: 15000 });
|
||||
await expect(page.locator('#root')).toBeVisible();
|
||||
await page.waitForTimeout(600);
|
||||
for (const route of SETTINGS_ROUTES) {
|
||||
test(`no horizontal overflow after full scroll on ${route}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
for (const viewport of MOBILE_VIEWPORTS) {
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto(route, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForURL(/\/settings/, { timeout: 15000 });
|
||||
await expect(page.locator('#root')).toBeVisible();
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
await scrollToBottom(page);
|
||||
const atBottom = await page.evaluate(() => {
|
||||
const scrollTop = window.scrollY;
|
||||
const maxScrollTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
|
||||
return scrollTop >= maxScrollTop - 3;
|
||||
});
|
||||
expect(atBottom, `Expected to reach the bottom while auditing ${route}`).toBeTruthy();
|
||||
|
||||
const audit = await auditHorizontalOverflow(page);
|
||||
expect(
|
||||
audit.pageWidth,
|
||||
`Mobile overflow on ${route} (viewport=${audit.viewportWidth}, page=${audit.pageWidth}, offenders=${JSON.stringify(audit.offenders)})`,
|
||||
).toBeLessThanOrEqual(audit.viewportWidth + 1);
|
||||
await scrollToBottom(page);
|
||||
const atBottom = await page.evaluate(() => {
|
||||
const scrollTop = window.scrollY;
|
||||
const maxScrollTop = Math.max(
|
||||
0,
|
||||
document.documentElement.scrollHeight - window.innerHeight,
|
||||
);
|
||||
return scrollTop >= maxScrollTop - 3;
|
||||
});
|
||||
}
|
||||
expect(
|
||||
atBottom,
|
||||
`Expected to reach the bottom while auditing ${route} on a ${viewport.label}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const audit = await auditHorizontalOverflow(page);
|
||||
expect(
|
||||
audit.pageWidth,
|
||||
`Mobile overflow on ${route} at ${viewport.width}px (viewport=${audit.viewportWidth}, page=${audit.pageWidth}, offenders=${JSON.stringify(audit.offenders)})`,
|
||||
).toBeLessThanOrEqual(audit.viewportWidth + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue