This commit is contained in:
@@ -105,6 +105,7 @@ function parseArgs(argv) {
|
||||
routes: [],
|
||||
viewports: DEFAULT_VIEWPORTS,
|
||||
timeoutMs: 30000,
|
||||
waitUntil: 'commit',
|
||||
settleMs: 350,
|
||||
maxOverflow: 1,
|
||||
screenshotDir: '',
|
||||
@@ -128,6 +129,8 @@ function parseArgs(argv) {
|
||||
options.viewports = [];
|
||||
} else if (arg === '--timeout') {
|
||||
options.timeoutMs = parseInt(argv[++i], 10) * 1000;
|
||||
} else if (arg === '--wait-until') {
|
||||
options.waitUntil = argv[++i];
|
||||
} else if (arg === '--settle-ms') {
|
||||
options.settleMs = parseInt(argv[++i], 10);
|
||||
} else if (arg === '--max-overflow') {
|
||||
@@ -165,6 +168,7 @@ Options:
|
||||
--viewport name=WxH Add a viewport
|
||||
--clear-default-viewports Use only custom --viewport entries
|
||||
--timeout SEC Navigation timeout, default 30
|
||||
--wait-until EVENT Playwright navigation event, default commit
|
||||
--settle-ms MS Fixed post-DOM layout settle wait, default 350
|
||||
--max-overflow PX Allowed body overflow, default 1
|
||||
--screenshot-dir DIR Save failure screenshots
|
||||
@@ -215,6 +219,32 @@ function safeName(input) {
|
||||
return input.replace(/[^a-z0-9]+/gi, '_').replace(/^_+|_+$/g, '').toLowerCase() || 'root';
|
||||
}
|
||||
|
||||
async function createViewportPage(context, viewport) {
|
||||
const page = await context.newPage();
|
||||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||
return page;
|
||||
}
|
||||
|
||||
async function closePage(page) {
|
||||
if (!page) {
|
||||
return;
|
||||
}
|
||||
await Promise.race([
|
||||
page.close().catch(() => {}),
|
||||
new Promise((resolve) => setTimeout(resolve, 1000)),
|
||||
]);
|
||||
}
|
||||
|
||||
async function closeWithTimeout(target, timeoutMs = 1500) {
|
||||
if (!target || typeof target.close !== 'function') {
|
||||
return;
|
||||
}
|
||||
await Promise.race([
|
||||
target.close().catch(() => {}),
|
||||
new Promise((resolve) => setTimeout(resolve, timeoutMs)),
|
||||
]);
|
||||
}
|
||||
|
||||
async function collectMetrics(page, maxOverflow) {
|
||||
return page.evaluate(
|
||||
({ localScrollSelectors, maxOverflowPx }) => {
|
||||
@@ -269,6 +299,22 @@ async function collectMetrics(page, maxOverflow) {
|
||||
);
|
||||
}
|
||||
|
||||
function formatResult(result) {
|
||||
const status = result.passed ? 'PASS' : 'FAIL';
|
||||
const overflow = result.metrics ? `${result.metrics.overflow}px` : 'n/a';
|
||||
const localScroll = result.metrics ? result.metrics.localScroll.length : 0;
|
||||
return `${status} ${result.viewport} ${result.route} overflow=${overflow} local_scroll=${localScroll}${result.error ? ` error=${result.error}` : ''}`;
|
||||
}
|
||||
|
||||
function printResult(result) {
|
||||
console.log(formatResult(result));
|
||||
if (!result.passed && result.metrics && result.metrics.offenders.length) {
|
||||
for (const offender of result.metrics.offenders) {
|
||||
console.log(` offender ${offender.tag}.${offender.className} right=${offender.right} text="${offender.text}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const { chromium } = requirePlaywright();
|
||||
@@ -283,15 +329,13 @@ async function main() {
|
||||
}
|
||||
|
||||
const browser = await chromium.launch(launchOptions);
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const results = [];
|
||||
const pages = new Map();
|
||||
|
||||
try {
|
||||
for (const viewport of options.viewports) {
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: viewport.width, height: viewport.height },
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
const page = await createViewportPage(context, viewport);
|
||||
pages.set(viewport.name, page);
|
||||
}
|
||||
|
||||
@@ -302,9 +346,11 @@ async function main() {
|
||||
let status = 0;
|
||||
let error = '';
|
||||
let metrics = null;
|
||||
let resetPage = false;
|
||||
|
||||
try {
|
||||
const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: options.timeoutMs });
|
||||
const response = await page.goto(url, { waitUntil: options.waitUntil, timeout: options.timeoutMs });
|
||||
await page.waitForSelector('body', { timeout: Math.min(options.timeoutMs, 5000) }).catch(() => {});
|
||||
await page.evaluate(() => document.fonts && document.fonts.ready).catch(() => {});
|
||||
if (options.settleMs > 0) {
|
||||
await page.waitForTimeout(options.settleMs);
|
||||
@@ -322,37 +368,35 @@ async function main() {
|
||||
}
|
||||
} catch (err) {
|
||||
error = err.message || String(err);
|
||||
resetPage = true;
|
||||
}
|
||||
|
||||
const passed = !error;
|
||||
const result = { route, viewport: viewport.name, status, passed, error, metrics };
|
||||
results.push(result);
|
||||
if (!options.json) {
|
||||
printResult(result);
|
||||
}
|
||||
|
||||
if ((options.screenshotAll || !passed) && options.screenshotDir) {
|
||||
const file = `${safeName(route)}_${safeName(viewport.name)}.png`;
|
||||
await page.screenshot({ path: path.join(options.screenshotDir, file), fullPage: false });
|
||||
}
|
||||
|
||||
if (resetPage) {
|
||||
await closePage(page);
|
||||
pages.set(viewport.name, await createViewportPage(context, viewport));
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await Promise.all(Array.from(pages.values()).map((page) => page.close().catch(() => {})));
|
||||
await browser.close();
|
||||
await Promise.all(Array.from(pages.values()).map((page) => closePage(page)));
|
||||
await closeWithTimeout(context);
|
||||
await closeWithTimeout(browser);
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(results, null, 2));
|
||||
} else {
|
||||
for (const result of results) {
|
||||
const status = result.passed ? 'PASS' : 'FAIL';
|
||||
const overflow = result.metrics ? `${result.metrics.overflow}px` : 'n/a';
|
||||
const localScroll = result.metrics ? result.metrics.localScroll.length : 0;
|
||||
console.log(`${status} ${result.viewport} ${result.route} overflow=${overflow} local_scroll=${localScroll}${result.error ? ` error=${result.error}` : ''}`);
|
||||
if (!result.passed && result.metrics && result.metrics.offenders.length) {
|
||||
for (const offender of result.metrics.offenders) {
|
||||
console.log(` offender ${offender.tag}.${offender.className} right=${offender.right} text="${offender.text}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const failed = results.filter((result) => !result.passed);
|
||||
|
||||
Reference in New Issue
Block a user