// Visual-bug audit: every bundled plugin (Emoji Picker excluded) and the visual Tweaks over the // X-shaped layout fixtures (scripts/visual-bugs/x-fixture.mjs) with the BUILT X runtime // (src-tauri/x-runtime.js), per route x width x theme. Per cell it checks, in the page: // header-overlap text in a post header (name, @handle, time, Exact Timestamps' date) painted // under X's header buttons (Grok, ···, Copy as Markdown) // orphan a hidden count that left its word or separator behind ("· Views"), or a gap // empty-card a bordered box in the right column with nothing visible in it // chat-cover X's floating Chat button over an inline reply/post box // overflow horizontal page scroll, an action bar wider than its post, plugin UI off screen // stretched a plugin pill stretched across the column // and saves screenshots to //. // // node scripts/build-inject.mjs && node scripts/visual-bugs.mjs [--phase=after] [--widths=360,600,1000,1400] // [--variants=x-dark,x-light,cards-dark,cards-light] [--routes=home,status,...] [--scenarios=plugins,tweaks] // [--runtime=path/to/x-runtime.js --plugins=dir] (e.g. a copy of the previous build, for "before" shots) // [--out=../../verification/visual-bugs] [--strict] (--strict: exit 1 on any issue) // Fixtures approximate x.com's markup and layout rules; passing here is not a live X test. import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { chromium } from 'playwright'; import { ROUTE_NAMES, fixtureHtml, routePath } from './visual-bugs/x-fixture.mjs'; const app = fileURLToPath(new URL('../', import.meta.url)); const args = Object.fromEntries(process.argv.slice(2).map(arg => { const [k, ...v] = arg.replace(/^--/, '').split('='); return [k, v.length ? v.join('=') : true]; })); const list = (value, fallback) => (typeof value === 'string' ? value.split(',').filter(Boolean) : fallback); const phase = args.phase || 'after'; const out = resolve(args.out || join(app, '../../verification/visual-bugs'), phase); const runtime = await readFile(resolve(args.runtime || join(app, 'src-tauri/x-runtime.js')), 'utf8'); const pluginDir = resolve(args.plugins || join(app, 'src-tauri/bundled-plugins')); const widths = list(args.widths, ['360', '600', '1000', '1400']).map(Number); const variants = list(args.variants, ['x-dark', 'x-light', 'cards-dark', 'cards-light']); const routes = list(args.routes, ROUTE_NAMES); const scenarios = list(args.scenarios, ['plugins', 'tweaks']); const VARIANTS = { 'x-dark': { mode: 'dark', preset: 'x' }, 'x-light': { mode: 'light', preset: 'x' }, 'cards-dark': { mode: 'dark', preset: 'nord' }, 'cards-light': { mode: 'light', preset: 'paper' }, }; const PLUGINS = ['thread-reader', 'exact-timestamps', 'clean-links', 'copy-as-markdown', 'alt-text-reminder', 'user-notes', 'muted-regex', 'reading-time', 'media-grid-focus', 'keyboard-plus', 'hide-blue-checks', 'focus-mode', 'reply-guard']; const TWEAKS = { hideViewCounts: true, hideEngagementCounts: true, hideSidebarTrends: true, hideWhoToFollow: true, hidePremiumUpsells: true, hideTodaysNews: true, hideLiveOnX: true, hideRelevantPeople: true, hideProfileWhoToFollow: true, hideBookmarkButton: true, hideSubscribeButtons: true, fullNumbers: true, hideGrok: false, }; const sources = Object.fromEntries(await Promise.all(PLUGINS.map(async name => [name, await readFile(join(pluginDir, `${name}.plugin.js`), 'utf8')]))); await mkdir(out, { recursive: true }); const browser = await chromium.launch({ headless: true, executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE || undefined }); /** Runs in the page: the checks. Returns [{ kind, detail }]. */ function inspect(opts) { const issues = []; const add = (kind, detail) => issues.push({ kind, detail }); const rendered = el => { for (let e = el; e && e.nodeType === 1; e = e.parentElement) { const s = getComputedStyle(e); if (s.display === 'none' || s.visibility === 'hidden' || +s.opacity === 0) return false; } return true; }; // The part of `el` that is actually painted: clipped by every overflow-hidden ancestor. const paintRect = el => { let r = el.getBoundingClientRect(); let box = { left: r.left, top: r.top, right: r.right, bottom: r.bottom }; for (let e = el.parentElement; e && e !== document.body; e = e.parentElement) { const s = getComputedStyle(e); if (s.overflowX !== 'visible' || s.overflowY !== 'visible') { const c = e.getBoundingClientRect(); box = { left: Math.max(box.left, c.left), top: Math.max(box.top, c.top), right: Math.min(box.right, c.right), bottom: Math.min(box.bottom, c.bottom) }; } } return box.right - box.left > 0.5 && box.bottom - box.top > 0.5 ? box : null; }; const hit = (a, b, slack = 1) => a && b && a.left < b.right - slack && a.right > b.left + slack && a.top < b.bottom - slack && a.bottom > b.top + slack; // Text leaves: elements whose own text nodes paint something. const textLeaves = root => [...root.querySelectorAll('*')].filter(el => [...el.childNodes].some(n => n.nodeType === 3 && n.nodeValue.trim()) && rendered(el)); const label = el => (el.className && typeof el.className === 'string' ? el.className.split(' ').find(c => c.startsWith('bt-')) : '') || el.getAttribute('data-testid') || el.tagName.toLowerCase(); // header-overlap for (const article of document.querySelectorAll('article[data-testid="tweet"]')) { const buttons = [...article.querySelectorAll('[data-testid="caret"], button[aria-label="Grok actions"], .bt-copy-md')].filter(rendered).map(b => ({ b, r: paintRect(b) })).filter(x => x.r); const name = article.querySelector('[data-testid="User-Name"]'); if (!name || !buttons.length) continue; let row = name; while (row.parentElement && row !== article && !row.parentElement.querySelector('[data-testid="caret"]')) row = row.parentElement; const header = row.parentElement; for (const leaf of textLeaves(header)) { if (leaf.closest('button')) continue; const r = paintRect(leaf); const over = buttons.find(x => hit(r, x.r)); if (over) add('header-overlap', `"${leaf.textContent.trim().slice(0, 40)}" (${label(leaf)}) under ${label(over.b)}`); } } // orphan: the focal post's date line, follower counts for (const meta of document.querySelectorAll('[data-fx="meta"]')) { const shown = meta.innerText.replace(/\s+/g, ' ').trim(); if (opts.viewsHidden && /Views/.test(shown)) add('orphan', `date line still shows "${shown}"`); if (/·\s*$/.test(shown)) add('orphan', `date line ends in a separator: "${shown}"`); } if (opts.followHidden) { for (const link of document.querySelectorAll('a[href$="/following"], a[href$="/followers"], a[href$="/verified_followers"]')) { if (!rendered(link)) continue; const shown = link.innerText.replace(/\s+/g, ' '); if (/\d/.test(shown)) add('orphan', `follower count still shows "${shown.trim()}"`); const first = textLeaves(link).map(paintRect).filter(Boolean).sort((a, b) => a.left - b.left)[0]; const box = link.getBoundingClientRect(); if (first && first.left - box.left > 3) add('orphan', `gap of ${Math.round(first.left - box.left)}px before "${shown.trim()}"`); } } // empty-card const side = document.querySelector('[data-testid="sidebarColumn"]'); if (side && rendered(side)) { for (const el of side.querySelectorAll('*')) { const s = getComputedStyle(el); const bordered = ['Top', 'Bottom', 'Left', 'Right'].some(k => parseFloat(s[`border${k}Width`]) >= 1 && s[`border${k}Style`] !== 'none'); if (!bordered || !rendered(el)) continue; const r = el.getBoundingClientRect(); if (r.height < 1 || r.width < 1) continue; const content = el.innerText.trim() || [...el.querySelectorAll('svg, img, input')].some(x => rendered(x) && x.getBoundingClientRect().width > 0); if (!content) add('empty-card', `empty bordered box ${Math.round(r.width)}x${Math.round(r.height)} at y=${Math.round(r.top)}`); } } // chat-cover const chat = document.querySelector('[data-testid="chat-drawer-root"]'); if (chat && rendered(chat)) { const c = chat.querySelector('button').getBoundingClientRect(); for (const button of document.querySelectorAll('[data-testid="primaryColumn"] [data-testid="tweetButtonInline"]')) { let box = button; while (box && !box.querySelector('[data-testid^="tweetTextarea_"]')) box = box.parentElement; const r = (box || button).getBoundingClientRect(); if (r.bottom > 0 && r.top < innerHeight && hit(c, r, 0)) add('chat-cover', `Chat button over the "${button.textContent.trim()}" box`); } } // overflow if (document.scrollingElement.scrollWidth > innerWidth + 1) add('overflow', `page scrolls sideways (${document.scrollingElement.scrollWidth} > ${innerWidth})`); for (const group of document.querySelectorAll('article [role="group"]')) { if (rendered(group) && group.scrollWidth > group.clientWidth + 1) add('overflow', `action bar ${group.scrollWidth} > ${group.clientWidth}`); } for (const el of document.querySelectorAll('[class*="bt-"], [class^="bt-"]')) { if (!rendered(el) || el.closest('#layers, wb-ui')) continue; const r = paintRect(el); if (r && (r.right > innerWidth + 1 || r.left < -1)) add('overflow', `${label(el)} off screen (${Math.round(r.left)}..${Math.round(r.right)})`); } // stretched for (const el of document.querySelectorAll('.bt-reading-time')) { if (rendered(el) && el.getBoundingClientRect().width > 160) add('stretched', `${label(el)} is ${Math.round(el.getBoundingClientRect().width)}px wide`); } return issues; } async function openCell({ route, width, variant, scenario }) { const v = VARIANTS[variant]; const context = await browser.newContext({ viewport: { width, height: 800 }, deviceScaleFactor: 1 }); const page = await context.newPage(); const errors = []; page.on('pageerror', error => errors.push(error.message)); await page.route('https://x.com/**', r => r.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: fixtureHtml(route, v.mode, width) })); await page.route('https://pbs.twimg.com/**', r => r.fulfill({ status: 404, body: '' })); await page.goto(`https://x.com${routePath(route)}`); if (scenario === 'tweaks') await page.evaluate(t => { window.__btFeatures = t; }, TWEAKS); await page.addScriptTag({ content: runtime }); await page.waitForFunction(() => !!window.wayBetter?.api); await page.evaluate(p => wayBetter.theme.apply({ preset: p }), v.preset); if (scenario === 'plugins') { await page.evaluate(() => { const api = wayBetter.api; api.storage('focus-mode').set('settings', { enabled: true, metrics: true, trends: true, whoToFollow: true, followCounts: true, sidebar: false }); api.storage('user-notes').set('placeholder', 'Met at the Tauri meetup. Prefers DMs over mentions; writes long threads about layout.'); api.storage('muted-regex').set('patterns', 'crypto giveaway'); }); for (const name of PLUGINS) { await page.evaluate(([n, source]) => { const module = { exports: {} }; new Function('module', 'exports', source)(module, module.exports); const plugin = new module.exports(); const run = () => plugin.start(); // With the plugin's declared @permissions, as the app runs it (no line: legacy, full API). const declared = /@permissions[ \t]+([^\r\n]*)/.exec(source); const grants = declared ? declared[1].split(',').map(x => x.trim()).filter(Boolean) : null; if (typeof wayBetter.withPlugin === 'function') wayBetter.withPlugin(n, grants, run); else run(); (window.__plugins ||= {})[n] = plugin; }, [name, sources[name]]); } } await page.waitForTimeout(350); return { context, page, errors }; } /** Scroll so the part a route is about is in view (the reply box sits at the bottom on post pages). */ async function frame(page, route) { if (route === 'status' || route === 'status-other') { await page.evaluate(() => { const box = document.querySelector('[data-testid="tweetButtonInline"]'); const rect = box.getBoundingClientRect(); window.scrollBy(0, rect.bottom - innerHeight + 24); }); } else if (route === 'home' || route === 'search') { await page.evaluate(() => window.scrollTo(0, 0)); } await page.waitForTimeout(250); } const results = []; for (const scenario of scenarios) { for (const route of routes) { if (scenario === 'tweaks' && route === 'media') continue; for (const variant of variants) { for (const width of widths) { const { context, page, errors } = await openCell({ route, width, variant, scenario }); await frame(page, route); const issues = await page.evaluate(inspect, { viewsHidden: true, followHidden: scenario === 'plugins' }); for (const message of errors) issues.push({ kind: 'page-error', detail: message }); const name = `${scenario}-${route}-${variant}-${width}`; await page.screenshot({ path: join(out, `${name}.png`) }); const unique = [...new Map(issues.map(i => [`${i.kind}:${i.detail}`, i])).values()]; results.push({ scenario, route, variant, width, issues: unique }); console.log(`${unique.length ? 'ISSUES' : 'ok '} ${name}${unique.length ? `\n - ${unique.map(i => `${i.kind}: ${i.detail}`).join('\n - ')}` : ''}`); await context.close(); } } } } await browser.close(); await writeFile(join(out, 'results.json'), JSON.stringify(results, null, 2)); const total = results.reduce((n, r) => n + r.issues.length, 0); const byKind = {}; for (const r of results) for (const i of r.issues) byKind[i.kind] = (byKind[i.kind] || 0) + 1; console.log(`\n${results.length} cells, ${total} issues ${JSON.stringify(byKind)} -> ${out}`); if (args.strict && total) process.exit(1);