import { chromium } from '@playwright/test'; const MID = 'a1111111-1111-1111-1111-111111111111'; const CID = 'c1111111-1111-1111-1111-111111111111'; const browser = await chromium.launch(); const ctx = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 1 }); const page = await ctx.newPage(); page.on('pageerror', e => console.log(' PAGE ERROR:', e.message)); // Generic stubs so every page renders deterministically without a backend async function mock() { await page.unrouteAll().catch(() => {}); await page.route('**/api/v1/auth/config', r => r.fulfill({ status: 200, contentType: 'application/json', body: '{"self_register_enabled":true,"private_mode":false}' })); await page.route('**/api/v1/auth/me', r => r.fulfill({ status: 401, contentType: 'application/json', body: '{"error":{"code":"x","message":"x"}}' })); await page.route('**/api/v1/auth/me/preferences', r => r.fulfill({ status: 401, contentType: 'application/json', body: '{}' })); await page.route('**/api/v1/me/bookmarks*', r => r.fulfill({ status: 401, contentType: 'application/json', body: '{}' })); await page.route('**/api/v1/me/collections*', r => r.fulfill({ status: 401, contentType: 'application/json', body: '{}' })); await page.route('**/api/v1/me/read-progress*', r => r.fulfill({ status: 401, contentType: 'application/json', body: '{}' })); await page.route('**/api/v1/mangas/**', r => { const url = new URL(r.request().url()); const segs = url.pathname.split('/'); const last = segs[segs.length - 1]; if (last === MID) { return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ id: MID, title: 'A Very Long Manga Title That Should Ellipsize On Narrow Screens', status: 'ongoing', alt_titles: ['Alt One', 'Alt Two'], // Realistic worst case: a normal-length lorem-ipsum prefix // followed by a long unbreakable token (e.g. a crawl-source // URL or a romanized title with no separators). The token // is what pushed the description past the screen edge on // user devices. description: 'Lorem ipsum dolor sit amet '.repeat(8) + ' AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', cover_image_path: `mangas/${MID}/cover.png`, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', authors: [{ id: 'au1', name: 'Kentaro-Miura-with-an-extremely-long-author-name' }], genres: [{ id: 'g1', name: 'Action' }, { id: 'g2', name: 'Dark Fantasy' }, { id: 'g3', name: 'Adventure' }], // Mix of normal-length and one extreme tag with no soft-break // opportunities — exercises chip wrap behavior under stress. tags: [ { id: 't1', name: 'psychological', added_by: null }, { id: 't2', name: 'school', added_by: null }, { id: 't3', name: 'SuperLongUnbrokenTagNameThatDoesNotFitInAnyReasonableChipWidth', added_by: null } ] }) }); } if (last === CID) { return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ id: CID, manga_id: MID, number: 1, title: 'The Brand', page_count: 3, created_at: '2026-01-01T00:00:00Z' }) }); } if (last === 'pages') { return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ pages: [ { id: 'p1', chapter_id: CID, page_number: 1, storage_key: 'x.png', content_type: 'image/png' }, { id: 'p2', chapter_id: CID, page_number: 2, storage_key: 'y.png', content_type: 'image/png' }, { id: 'p3', chapter_id: CID, page_number: 3, storage_key: 'z.png', content_type: 'image/png' } ] }) }); } // Mangas list or chapters list if (url.pathname.endsWith('/chapters')) { return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [{ id: CID, manga_id: MID, number: 1, title: 'The Brand', page_count: 3, created_at: '2026-01-01T00:00:00Z' }], page: { limit: 50, offset: 0, total: 1 } }) }); } const items = Array.from({ length: 8 }, (_, i) => ({ id: `m${i + 1}`, title: `Manga ${i + 1} with a long title that ellipsizes`, status: 'ongoing', alt_titles: [], description: null, cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', authors: [{ id: 'a1', name: 'Author' }], genres: [{ id: 'g1', name: 'Action' }], tags: [] })); return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items, page: { limit: 50, offset: 0, total: 8 } }) }); }); await page.route('**/api/v1/genres*', r => r.fulfill({ status: 200, contentType: 'application/json', body: '[]' })); await page.route('**/api/v1/files/**', r => r.fulfill({ status: 200, contentType: 'image/png', body: Buffer.from('89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63000100000005000158a3b62a0000000049454e44ae426082', 'hex') })); } const routes = [ ['/', 'catalog'], ['/login', 'login'], ['/register', 'register'], ['/upload', 'upload'], [`/manga/${MID}`, 'detail'], [`/manga/${MID}/chapter/${CID}`, 'reader'], ['/library', 'library'], ['/profile', 'profile-overview'], ['/profile/account','account'], ['/profile/preferences','preferences'], ['/bookmarks', 'bookmarks-top'], ['/collections', 'collections-top'] ]; // Identify elements that "kiss" the viewport edges — their right // edge is essentially flush with the screen edge. For interactive // controls (buttons, anchors, inputs, .chip*) this is almost always // a design bug rather than intentional full-bleed chrome. function bleeders(thresholdPx) { const vw = window.innerWidth; const bad = []; const candidates = document.querySelectorAll('button, a, input, select, textarea, [class*="chip"], [class*="card"], h1, h2, h3, p'); for (const el of candidates) { const r = el.getBoundingClientRect(); if (r.width === 0) continue; // Skip elements whose ancestor is itself position:fixed at the // edges — that's intentional chrome (bottom nav, CTA bar). let p = el; let inFixed = false; while (p) { const cs = getComputedStyle(p); if (cs.position === 'fixed') { inFixed = true; break; } p = p.parentElement; } if (inFixed) continue; // Touching right edge? if (vw - r.right < thresholdPx) { const tag = el.tagName.toLowerCase(); const cls = (typeof el.className === 'string' ? el.className : '').split(' ').filter(c => !!c && !c.startsWith('s-')).slice(0, 2).join('.'); const id = el.dataset.testid ?? cls; bad.push(`right-bleed ${tag}.${id} right=${Math.round(r.right)}`); } // Touching left edge? if (r.left < thresholdPx) { const tag = el.tagName.toLowerCase(); const cls = (typeof el.className === 'string' ? el.className : '').split(' ').filter(c => !!c && !c.startsWith('s-')).slice(0, 2).join('.'); const id = el.dataset.testid ?? cls; bad.push(`left-bleed ${tag}.${id} left=${Math.round(r.left)}`); } } // de-dup return [...new Set(bad)]; } for (const [path, name] of routes) { await mock(); try { await page.goto('http://localhost:5173' + path, { waitUntil: 'networkidle', timeout: 8000 }); } catch (e) { console.log(`${name.padEnd(20)} NAV ERROR: ${e.message.slice(0, 60)}`); continue; } await page.waitForTimeout(400); const m = await page.evaluate(() => { const all = Array.from(document.querySelectorAll('*')); const vw = window.innerWidth; const offenders = []; for (const el of all) { const r = el.getBoundingClientRect(); if (r.right > vw + 0.5 || r.width > vw + 0.5) { const tag = el.tagName.toLowerCase(); const className = typeof el.className === 'string' ? el.className.split(' ').filter(c => !!c).slice(0, 2).join('.') : ''; const id = el.dataset.testid ?? className; offenders.push(`${tag}.${id} right=${Math.round(r.right)} w=${Math.round(r.width)}`); } } // Detect horizontal overflow from scroll measurement, but skip // elements that intentionally clip via overflow:hidden — that's // ellipsization, not a layout bug. const scrollers = []; for (const el of all) { if (el.scrollWidth > el.clientWidth + 0.5) { const cs = getComputedStyle(el); if (cs.overflowX === 'hidden' || cs.overflowX === 'scroll' || cs.overflowX === 'auto') continue; const tag = el.tagName.toLowerCase(); const className = typeof el.className === 'string' ? el.className.split(' ').filter(c => !!c).slice(0, 2).join('.') : ''; const id = el.dataset.testid ?? className; // Walk up to find the offending child too let child = ''; for (const c of el.children) { const r = c.getBoundingClientRect(); const er = el.getBoundingClientRect(); if (r.right > er.right + 0.5) { child = ` child:${c.tagName.toLowerCase()}.${typeof c.className === 'string' ? c.className.split(' ').filter(x => !!x).slice(0, 2).join('.') : ''}(right=${Math.round(r.right)})`; break; } } scrollers.push(`${tag}.${id} scrollW=${el.scrollWidth} clientW=${el.clientWidth}${child}`); } } return { innerWidth: vw, docW: document.documentElement.scrollWidth, bodyW: document.body.scrollWidth, offenders: offenders.slice(0, 8), scrollers: scrollers.slice(0, 8) }; }); // Also flag elements that touch the viewport edges (no inset gutter) const bleed = await page.evaluate((b) => { // re-execute the bleeders function here because page context is fresh const vw = window.innerWidth; const threshold = 4; const bad = []; const candidates = document.querySelectorAll('button, a, input, select, textarea, [class*="chip"], [class*="card"], h1, h2, h3, p'); for (const el of candidates) { const r = el.getBoundingClientRect(); if (r.width === 0) continue; let p = el; let inFixed = false; while (p) { const cs = getComputedStyle(p); if (cs.position === 'fixed') { inFixed = true; break; } p = p.parentElement; } if (inFixed) continue; const tag = el.tagName.toLowerCase(); const cls = (typeof el.className === 'string' ? el.className : '').split(' ').filter(c => !!c && !c.startsWith('s-')).slice(0, 2).join('.'); const id = el.dataset.testid ?? cls; if (vw - r.right < threshold) bad.push(`right-bleed ${tag}.${id} right=${Math.round(r.right)}`); if (r.left < threshold) bad.push(`left-bleed ${tag}.${id} left=${Math.round(r.left)}`); } return [...new Set(bad)]; }); const safe = m.docW <= m.innerWidth && m.bodyW <= m.innerWidth && m.offenders.length === 0 && m.scrollers.length === 0 && bleed.length === 0; console.log(`${name.padEnd(20)} ${safe ? '✅' : '⚠️ '} doc=${m.docW} body=${m.bodyW} inner=${m.innerWidth}`); if (m.offenders.length) { console.log(' --- elements past viewport ---'); for (const o of m.offenders) console.log(' ', o); } if (m.scrollers.length) { console.log(' --- horizontal scrollers ---'); for (const s of m.scrollers) console.log(' ', s); } if (bleed.length) { console.log(' --- edge bleeders (touch viewport) ---'); for (const b of bleed.slice(0, 12)) console.log(' ', b); } await page.screenshot({ path: `/tmp/audit-${name}.png`, fullPage: false }); } await browser.close();