226 lines
14 KiB
JavaScript
226 lines
14 KiB
JavaScript
// 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 <out>/<phase>/.
|
|
//
|
|
// 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);
|