wayBetter for X

This commit is contained in:
CRTkafa 2026-10-01 06:01:13 +07:00
commit df5dff619e
720 changed files with 147465 additions and 0 deletions

View file

@ -0,0 +1,539 @@
// Browser check of the bundled Emoji Picker (3.x) with the BUILT X runtime (src-tauri/x-runtime.js)
// on X-shaped composer pages served at https://x.com/ (Playwright route, no network; see
// scripts/emoji-picker-fixture.mjs): the home composer, the reply box on a post page and the modal
// composer of /compose/post. Screenshots the picker opened from X's emoji button in each of them in
// X Lights out, Paper and Tokyo Night, in a 1180 px and a 420 px pane, plus search, the empty state,
// the skin tones, the inline `:shortcode` list and card close-ups; then checks placement, keyboard,
// insertion, theming and cleanup. Pages are served with X's real Content-Security-Policy; the
// runtime and the plugin are evaluated the way the app does (native script evaluation, which the
// page's CSP does not govern), and any CSP violation fails the run.
//
// node scripts/build-inject.mjs && node scripts/emoji-picker-verify.mjs [--label v3] [--out dir] [--shots-only]
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
import { composerPage, PATHS, xCsp } from './emoji-picker-fixture.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const app = resolve(here, '..');
const arg = (name, fallback) => { const i = process.argv.indexOf(name); return i > 0 ? process.argv[i + 1] : fallback; };
const label = arg('--label', 'v3');
const shotsOnly = process.argv.includes('--shots-only');
const out = resolve(arg('--out', join(app, '../../verification/emoji-picker-v3')));
await mkdir(out, { recursive: true });
const runtime = await readFile(join(app, 'src-tauri/x-runtime.js'), 'utf8');
const pluginSource = await readFile(join(app, 'src-tauri/bundled-plugins/emoji-picker.plugin.js'), 'utf8');
const THEMES = [['lightsout', 'dark', 'x'], ['paper', 'light', 'paper'], ['tokyo-night', 'dark', 'tokyo-night']];
const HEIGHT = 860;
const X_EMOJI = '[data-testid="toolBar"] [aria-label="Add emoji"]';
const HOST = 'wb-emoji-picker:not([data-closing])';
const editorOf = { home: '[data-testid="tweetTextarea_0"]', reply: '[data-testid="tweetTextarea_1"]', modal: '[role="dialog"] [data-testid="tweetTextarea_0"]' };
/** A policy stricter than X's today: inline <style> needs a nonce (the fixture's own styles carry it). */
const STRICT_STYLE_NONCE = 'c3RyaWN0LXN0eWxl';
const strictStyleCsp = () => xCsp().replace(/style-src [^;]+/, `style-src 'self' 'nonce-${STRICT_STYLE_NONCE}'`);
const results = [];
const check = (name, ok, detail = '') => {
results.push({ name, ok: !!ok, detail });
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` (${detail})` : ''}`);
};
const browser = await chromium.launch({ headless: true, executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE || undefined });
const errors = [];
const violations = [];
async function openPage(kind, mode, preset, width, height = HEIGHT, extra = {}) {
// `csp`: another policy than X's (the strict-style check expects violations and keeps them apart).
const { csp, ...contextOptions } = extra;
const context = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: 1, ...contextOptions });
const page = await context.newPage();
const tag = `${kind}/${preset}/${width}`;
page.on('pageerror', error => errors.push(`${tag}: ${error.message}`));
if (!csp) page.on('console', message => { if (/Content Security Policy/i.test(message.text())) violations.push(`${tag}: ${message.text().slice(0, 240)}`); });
// Like the app's initialization script: recorded before X's own code, not subject to its CSP.
await page.addInitScript(() => {
window.__csp = [];
document.addEventListener('securitypolicyviolation', e => window.__csp.push(`${e.effectiveDirective} ${e.blockedURI || 'inline'} ${(e.sample || '').slice(0, 80)}`), true);
});
const body = csp ? composerPage(kind, mode).replace(/<style>/g, `<style nonce="${STRICT_STYLE_NONCE}">`) : composerPage(kind, mode);
await page.route('https://x.com/**', route => route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', headers: { 'content-security-policy': csp || xCsp() }, body }));
await page.goto('https://x.com' + PATHS[kind]);
// The app evaluates the runtime and plugins natively (WebView2 ExecuteScript); a page-level
// <script> or new Function() would be refused by X's CSP, so neither is used here.
await page.evaluate(runtime);
await page.waitForFunction(() => !!window.wayBetter?.api?.ui);
await page.evaluate(p => wayBetter.theme.apply({ preset: p }), preset);
await page.evaluate(`(() => {
try { localStorage.clear(); } catch {}
const module = { exports: {} };
(function (module, exports) {
${pluginSource}
})(module, module.exports);
window.__EP = module.exports;
const plugin = new module.exports();
plugin.start();
// Some history, as after a few days of use.
for (const name of ['fire', 'joy', 'thumbsup', 'heart', 'eyes', 'rocket', 'pray', 'sob', 'fire', 'joy', 'fire', 'sparkles', '100', 'skull', 'wave', 'tada', 'clap', 'smile'])
plugin.recordUsage(module.exports.findExact(name));
window.__plugin = plugin;
})()`);
if (!csp) pages.push({ page, tag });
await page.waitForTimeout(80);
return { page, context };
}
const pages = [];
/** CSP violations the page recorded (securitypolicyviolation events) since it loaded. */
const collectCsp = async () => {
for (const { page, tag } of pages.splice(0)) {
try { for (const v of await page.evaluate(() => window.__csp || [])) violations.push(`${tag}: ${v}`); } catch { /* Closed. */ }
}
};
const shot = async (page, name) => { await page.waitForTimeout(220); await page.screenshot({ path: join(out, `${label}-${name}.png`) }); };
/** A close-up of the card with a little of the page around it. */
const cardShot = async (page, name, pad = 20) => {
await page.waitForTimeout(220);
const r = await page.evaluate(sel => { const b = document.querySelector(sel).shadowRoot.querySelector('.ep-card').getBoundingClientRect(); return { x: b.left, y: b.top, width: b.width, height: b.height }; }, HOST);
const vp = page.viewportSize();
const x = Math.max(0, Math.floor(r.x - pad));
const y = Math.max(0, Math.floor(r.y - 60));
await page.screenshot({ path: join(out, `${label}-${name}.png`), clip: { x, y, width: Math.min(vp.width - x, Math.ceil(r.width + pad * 2)), height: Math.min(vp.height - y, Math.ceil(r.height + 60 + pad)) } });
};
const openFromX = async (page, kind, text = 'Hello ') => {
await page.click(editorOf[kind]);
if (text) await page.keyboard.type(text);
await page.click(kind === 'modal' ? `[role="dialog"] ${X_EMOJI}` : kind === 'reply' ? `[data-composer="reply"] ${X_EMOJI}` : `[data-composer="home"] ${X_EMOJI}`);
await page.waitForTimeout(60);
};
/** Hovers the `n`th cell of the `row`th rendered row (shows the hover tile and the preview bar). */
const hoverCell = async (page, row = 1, n = 2) => {
const at = await page.evaluate(([sel, row, n]) => {
const root = document.querySelector(sel).shadowRoot;
const rows = [...root.querySelectorAll('.ep-row')].sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);
const cell = rows[row]?.children[n];
const r = cell?.getBoundingClientRect();
return r ? { x: r.left + r.width / 2, y: r.top + r.height / 2 } : null;
}, [HOST, row, n]);
if (at) await page.mouse.move(at.x, at.y);
};
const cardRect = page => page.evaluate(sel => {
const r = document.querySelector(sel)?.shadowRoot?.querySelector('.ep-card')?.getBoundingClientRect();
return r ? { left: r.left, top: r.top, right: r.right, bottom: r.bottom, width: r.width, height: r.height } : null;
}, HOST);
try {
// ------------------------------------------------------------------ screenshots
for (const [name, mode, preset] of THEMES) {
for (const width of [1180, 420]) {
for (const kind of ['home', 'reply', 'modal']) {
const tag = `${name}-${width}-${kind}`;
const { page, context } = await openPage(kind, mode, preset, width);
await openFromX(page, kind);
await hoverCell(page, 1, 3);
await shot(page, tag);
if (kind === 'home') {
await cardShot(page, `${name}-${width}-card`);
await page.mouse.move(5, 5);
await page.keyboard.type('hea');
await shot(page, `${name}-${width}-search`);
if (width === 1180) {
await page.keyboard.type('zzzq');
await shot(page, `${name}-${width}-empty`);
await page.evaluate(sel => document.querySelector(sel).shadowRoot.querySelector('.ep-tone-btn').click(), HOST);
await cardShot(page, `${name}-${width}-tones`);
}
await page.keyboard.press('Escape');
await page.keyboard.press('Escape');
}
if (kind === 'reply') {
await page.keyboard.press('Escape');
await page.click(editorOf.reply);
await page.keyboard.type(' so good :fir');
await shot(page, `${name}-${width}-autocomplete`);
}
await collectCsp(); await context.close();
}
}
}
// Our own toolbar button (setting off: X's emoji button keeps X's picker).
for (const [name, mode, preset] of THEMES) {
const { page, context } = await openPage('home', mode, preset, 1180);
await page.evaluate(() => window.__plugin.setReplaceX(false));
await page.hover('[data-composer="home"] .bt-ep-button');
await page.waitForTimeout(250);
await page.screenshot({ path: join(out, `${label}-${name}-1180-own-button.png`), clip: { x: 330, y: 60, width: 600, height: 170 } });
await collectCsp(); await context.close();
}
if (!shotsOnly) {
// ---------------------------------------------------------------- behavior
const { page, context } = await openPage('home', 'dark', 'x', 1180);
// Let the idle-time glyph warm-up run, as it does while someone reads the timeline.
await page.waitForTimeout(2000);
// ...and the glyph atlas worker that follows it (it shares the CPU with the measured open).
await page.waitForFunction(() => { const a = window.__plugin.atlas; return a.failed || (!a.queue.length && !a.inFlight && a.ready.size > 0); }, null, { timeout: 30000 }).catch(() => {});
const t0 = await page.evaluate(sel => {
document.querySelector('[data-testid="tweetTextarea_0"]').focus();
const start = performance.now();
document.querySelector('[data-composer="home"] [aria-label="Add emoji"]').click();
void document.querySelector(sel).shadowRoot.querySelector('.ep-card').offsetHeight;
return performance.now() - start;
}, HOST);
check("X's emoji button opens the picker; first open renders in < 16 ms (script + layout, after idle warm-up)", t0 < 16, `${t0.toFixed(1)} ms`);
await page.waitForTimeout(200); // the 120 ms open animation slides the card in
const dom = await page.evaluate(sel => {
const root = document.querySelector(sel)?.shadowRoot;
const cells = [...root.querySelectorAll('.ep-row')].reduce((a, r) => (r.children.length > a.length ? [...r.children] : a), []);
const lefts = [...cells].map(c => c.offsetLeft);
const hdr = root.querySelector('.ep-sticky');
return {
shadow: !!root,
cells: root?.querySelectorAll('[role="gridcell"]').length ?? 0,
grid: root?.querySelector('[role="grid"]')?.getAttribute('aria-rowcount'),
cols: root?.querySelector('[role="grid"]')?.getAttribute('aria-colcount'),
steps: lefts.slice(1).map((l, i) => l - lefts[i]),
live: root?.querySelector('[aria-live]')?.getAttribute('aria-live'),
firstHeader: hdr?.textContent, upper: getComputedStyle(hdr).textTransform,
active: root?.activeElement?.className,
rail: getComputedStyle(root.querySelector('.ep-rail')).gridRow,
radius: getComputedStyle(root.querySelector('.ep-card')).borderRadius,
cellRadius: getComputedStyle(root.querySelector('.ep-cell')).borderRadius,
size: [root.querySelector('.ep-card').offsetWidth, root.querySelector('.ep-card').offsetHeight],
xClicks: window.__xEmojiClicks, ours: document.querySelectorAll('.bt-ep-button').length,
};
}, HOST);
check('picker lives in its own shadow root', dom.shadow);
check("X's own emoji button handler never ran, and no second emoji button was added", dom.xClicks === 0 && dom.ours === 0, JSON.stringify({ xClicks: dom.xClicks, ours: dom.ours }));
check('card: 432 x 440, 8 px corners, category rail on the left', dom.size[0] === 432 && dom.size[1] === 440 && dom.radius === '8px' && /2/.test(dom.rail), JSON.stringify({ size: dom.size, radius: dom.radius, rail: dom.rail }));
check('grid: 9 columns of 40 px cells with 6 px hover tiles', dom.cols === '9' && dom.steps.length === 8 && dom.steps.every(s => s === 40) && dom.cellRadius === '6px', JSON.stringify({ cols: dom.cols, steps: dom.steps, cellRadius: dom.cellRadius }));
check('rows are virtualized (few cells rendered for ~1850 emoji)', dom.cells > 0 && dom.cells < 400, `${dom.cells} cells, ${dom.grid} rows`);
check('preview is an aria-live region', dom.live === 'polite');
check('Frequently used comes first, headers are uppercase', /Frequently used/i.test(dom.firstHeader || '') && dom.upper === 'uppercase', `${dom.firstHeader} / ${dom.upper}`);
check('search is focused on open', /ep-search-input/.test(dom.active || ''), dom.active);
// Glyph atlas: cells paint from worker-drawn sprite sheets (a color emoji glyph costs ~4 ms of
// raster each time the compositor draws it; an image does not), and the sheets are kept.
await page.waitForFunction(sel => document.querySelector(sel).shadowRoot.querySelectorAll('.ep-cell.ep-bm').length > 20, HOST, { timeout: 15000 }).catch(() => {});
const atlas = await page.evaluate(sel => {
const root = document.querySelector(sel).shadowRoot;
const cells = [...root.querySelectorAll('.ep-cell[data-g]')];
const bm = cells.filter(c => c.classList.contains('ep-bm'));
const first = bm[0];
return {
cells: cells.length, painted: bm.length,
image: first ? getComputedStyle(first).backgroundImage.slice(0, 10) : null,
textHidden: first ? getComputedStyle(first.firstElementChild).visibility : null,
text: first?.textContent === first?.dataset.g,
};
}, HOST);
check('grid cells paint from the glyph atlas (blob: sprite sheets), text glyph kept but unpainted', atlas.painted >= atlas.cells * 0.9 && /blob:/.test(atlas.image || '') && atlas.textHidden === 'hidden' && atlas.text, JSON.stringify(atlas));
const saved = await page.evaluate(() => new Promise(resolve => {
const request = indexedDB.open('wayBetter-emoji-atlas');
request.onsuccess = () => { try { const count = request.result.transaction('sheets').objectStore('sheets').count(); count.onsuccess = () => resolve(count.result); count.onerror = () => resolve(-1); } catch { resolve(-1); } };
request.onerror = () => resolve(-1);
}));
check('atlas sheets are kept in IndexedDB for the next page', saved > 0, `${saved} sheets`);
// Scroll the whole grid a frame at a step: every row that comes into view is painted from the atlas.
const scrolled = await page.evaluate(async sel => {
const root = document.querySelector(sel).shadowRoot;
const scroll = root.querySelector('.ep-scroll');
await new Promise(resolve => { const t = setInterval(() => { if (!window.__plugin.atlas.queue.length && !window.__plugin.atlas.inFlight) { clearInterval(t); resolve(); } }, 50); setTimeout(() => { clearInterval(t); resolve(); }, 20000); });
const frames = [];
let unpainted = 0;
let last = performance.now();
for (let top = 0; top < scroll.scrollHeight - scroll.clientHeight; top += 240) {
scroll.scrollTop = top;
await new Promise(r => requestAnimationFrame(r));
const now = performance.now();
frames.push(now - last);
last = now;
unpainted += root.querySelectorAll('.ep-cell[data-g]:not(.ep-bm)').length;
}
scroll.scrollTop = 0;
frames.sort((a, b) => a - b);
return { steps: frames.length, unpainted, p95: Math.round(frames[Math.floor(frames.length * 0.95)]), max: Math.round(frames.at(-1)) };
}, HOST);
check('whole grid scrolled: every row in view already painted from the atlas', scrolled.steps > 20 && scrolled.unpainted === 0, JSON.stringify(scrolled));
const where = await page.evaluate(sel => {
const card = document.querySelector(sel).shadowRoot.querySelector('.ep-card').getBoundingClientRect();
const button = document.querySelector('[data-composer="home"] [aria-label="Add emoji"]').getBoundingClientRect();
const toolbar = document.querySelector('[data-composer="home"] [data-testid="toolBar"]').getBoundingClientRect();
return { dx: Math.round(card.left - button.left), dy: Math.round(card.top - button.bottom), belowToolbar: card.top >= toolbar.bottom };
}, HOST);
check('home composer: the card opens under the toolbar, lined up with the emoji button', where.dx === 0 && where.dy === 8 && where.belowToolbar, JSON.stringify(where));
// Keyboard: arrows move in 2D, Enter inserts at the saved caret.
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowRight');
await page.keyboard.press('ArrowRight');
const sel = await page.evaluate(s => {
const root = document.querySelector(s).shadowRoot;
const c = root.querySelector('[aria-selected="true"]');
return { col: c?.getAttribute('aria-colindex'), row: c?.parentElement?.getAttribute('aria-rowindex'), preview: root.querySelector('.ep-preview-code')?.textContent };
}, HOST);
check('ArrowDown enters the grid, ArrowRight moves along the row, the preview shows the :shortcode:', sel.col === '3' && sel.row === '1' && /^:[a-z0-9_+-]+:$/.test(sel.preview || ''), JSON.stringify(sel));
await page.keyboard.press('ArrowDown');
const sel2 = await page.evaluate(s => {
const c = document.querySelector(s).shadowRoot.querySelector('[aria-selected="true"]');
return { col: c?.getAttribute('aria-colindex'), row: c?.parentElement?.getAttribute('aria-rowindex') };
}, HOST);
check('ArrowDown keeps the column', sel2.col === '3' && sel2.row === '2', JSON.stringify(sel2));
const expected = await page.evaluate(s => document.querySelector(s).shadowRoot.querySelector('[aria-selected="true"]').textContent, HOST);
await page.keyboard.press('Enter');
await page.waitForTimeout(50);
const after = await page.evaluate(s => ({
text: document.querySelector('[data-testid="tweetTextarea_0"]').textContent,
open: !!document.querySelector(s),
focus: document.activeElement?.getAttribute('data-testid'),
}), HOST);
check('Enter inserts the selected emoji at the caret and closes', after.text.endsWith(expected) && !after.open, JSON.stringify(after));
check('focus returns to the composer', after.focus === 'tweetTextarea_0', after.focus);
check("keys typed in the picker never reached X's shortcuts", await page.evaluate(() => !window.__xKeys.includes('ArrowRight')));
// Typing in the grid goes to search; PageDown jumps a category; Tab cycles; the rail is vertical.
await page.click(`[data-composer="home"] ${X_EMOJI}`);
await page.keyboard.press('ArrowDown');
await page.keyboard.press('PageDown');
const pd = await page.evaluate(s => document.querySelector(s).shadowRoot.querySelector('.ep-rail [aria-current="true"]')?.getAttribute('aria-label'), HOST);
check('PageDown jumps to the next category', pd && pd !== 'Frequently used', pd);
await page.keyboard.press('Tab');
const tab1 = await page.evaluate(s => document.querySelector(s).shadowRoot.activeElement?.closest('.ep-rail') != null, HOST);
check('Tab from the grid goes to the category rail', tab1);
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowRight');
const railKeys = await page.evaluate(s => {
const root = document.querySelector(s).shadowRoot;
return { focus: root.activeElement?.className, current: root.querySelector('.ep-rail [aria-current="true"]')?.getAttribute('aria-label') };
}, HOST);
check('rail: ArrowDown moves down the categories, ArrowRight enters that category', /ep-scroll/.test(railKeys.focus || '') && railKeys.current === 'People & Body', JSON.stringify(railKeys));
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
const tab2 = await page.evaluate(s => document.querySelector(s).shadowRoot.activeElement?.className, HOST);
check('Tab from the rail wraps to search', /ep-search-input/.test(tab2 || ''), tab2);
await page.keyboard.press('ArrowDown');
await page.keyboard.type('rock');
const typed = await page.evaluate(s => ({ q: document.querySelector(s).shadowRoot.querySelector('.ep-search-input').value, first: document.querySelector(s).shadowRoot.querySelector('[aria-selected="true"]')?.textContent, want: window.__EP.search('rock', 1)[0].emoji }), HOST);
check('typing while in the grid goes to search, first result selected', typed.q === 'rock' && typed.first === typed.want, JSON.stringify(typed));
await page.keyboard.press('Escape');
check('Esc closes', await page.evaluate(s => !document.querySelector(s), HOST));
// Scrolling far renders the right rows; sticky header follows.
await page.click(`[data-composer="home"] ${X_EMOJI}`);
await page.evaluate(s => { const sc = document.querySelector(s).shadowRoot.querySelector('.ep-scroll'); sc.scrollTop = sc.scrollHeight; }, HOST);
await page.waitForTimeout(120);
const bottom = await page.evaluate(s => {
const root = document.querySelector(s).shadowRoot;
return { sticky: root.querySelector('.ep-sticky')?.textContent, cells: root.querySelectorAll('[role="gridcell"]').length, rail: root.querySelector('.ep-rail [aria-current="true"]')?.getAttribute('aria-label') };
}, HOST);
check('scrolled to the end: Flags header sticky, rail follows, still virtualized', /Flags/.test(bottom.sticky || '') && bottom.rail === 'Flags' && bottom.cells < 400, JSON.stringify(bottom));
await cardShot(page, 'lightsout-1180-scrolled-flags');
// Outside click closes; clicking X's button again toggles it closed.
await page.mouse.click(1100, 800);
check('outside click closes', await page.evaluate(s => !document.querySelector(s), HOST));
await page.click(`[data-composer="home"] ${X_EMOJI}`);
await page.click(`[data-composer="home"] ${X_EMOJI}`);
await page.waitForTimeout(120);
check("a second click on X's emoji button closes it", await page.evaluate(s => !document.querySelector(s), HOST));
// Scrolling the page away closes it.
await page.click(`[data-composer="home"] ${X_EMOJI}`);
await page.mouse.move(1000, 600);
await page.mouse.wheel(0, 900);
await page.waitForTimeout(250);
check('scrolling the toolbar out of view closes the picker', await page.evaluate(s => !document.querySelector(s), HOST));
await page.evaluate(() => window.scrollTo(0, 0));
await page.waitForTimeout(60);
// Live theme change while open.
await page.click(`[data-composer="home"] ${X_EMOJI}`);
const bg1 = await page.evaluate(s => getComputedStyle(document.querySelector(s).shadowRoot.querySelector('.ep-card')).backgroundColor, HOST);
await page.evaluate(() => wayBetter.theme.apply({ preset: 'paper' }));
await page.waitForTimeout(120);
const bg2 = await page.evaluate(s => getComputedStyle(document.querySelector(s).shadowRoot.querySelector('.ep-card')).backgroundColor, HOST);
check('open picker repaints on theme change', bg1 !== bg2, `${bg1} -> ${bg2}`);
// Site CSS cannot reach it.
await page.addStyleTag({ content: 'button, div, input { color: red !important; background: yellow !important; font-family: serif !important; }' });
const iso = await page.evaluate(s => getComputedStyle(document.querySelector(s).shadowRoot.querySelector('.ep-card')).backgroundColor, HOST);
check("the site's CSS does not restyle the picker", iso !== 'rgb(255, 255, 0)', iso);
await page.keyboard.press('Escape');
await collectCsp(); await context.close();
// Ctrl+Shift+E opens at the same place as the button; inline autocomplete in the reply box.
{
const { page, context } = await openPage('reply', 'dark', 'x', 1180);
await page.click(editorOf.reply);
await page.keyboard.type('Nice ');
await page.keyboard.press('Control+Shift+KeyE');
await page.waitForTimeout(200);
const opened = await page.evaluate(s => {
const card = document.querySelector(s)?.shadowRoot.querySelector('.ep-card').getBoundingClientRect();
const button = document.querySelector('[data-composer="reply"] [aria-label="Add emoji"]').getBoundingClientRect();
const ed = document.querySelector('[data-testid="tweetTextarea_1"]').getBoundingClientRect();
if (!card) return null;
const covers = !(card.bottom <= ed.top || card.top >= ed.bottom || card.right <= ed.left || card.left >= ed.right);
return { dx: Math.round(card.left - button.left), covers, inside: card.top >= 0 && card.bottom <= innerHeight, h: Math.round(card.height) };
}, HOST);
check('Ctrl+Shift+E opens, lined up with the emoji button of that composer', opened && opened.dx === 0, JSON.stringify(opened));
check('reply box: inside the pane and not over the text being typed', opened && opened.inside && !opened.covers, JSON.stringify(opened));
await page.keyboard.press('Control+Shift+KeyE');
check('Ctrl+Shift+E closes', await page.evaluate(s => !document.querySelector(s), HOST));
await page.keyboard.type('hot :fir');
const ac = await page.evaluate(() => {
const list = document.querySelector('.bt-ep-ac[role="listbox"]');
const ed = document.querySelector('[data-testid="tweetTextarea_1"]');
return { themed: !!list && getComputedStyle(list).backgroundColor !== 'rgba(0, 0, 0, 0)', radius: list && getComputedStyle(list).borderRadius, head: list && getComputedStyle(list.querySelector('.bt-ep-ac-head')).textTransform, linked: ed.getAttribute('aria-activedescendant') === list?.querySelector('[aria-selected="true"]')?.id, n: list?.querySelectorAll('[role="option"]').length, sel: list?.querySelector('[aria-selected="true"] .bt-ep-ac-code')?.textContent };
});
check('inline list: same card language (8 px corners, uppercase header), first option selected and linked', ac.themed && ac.radius === '8px' && ac.head === 'uppercase' && ac.linked && ac.n > 0 && ac.sel === ':fire:', JSON.stringify(ac));
await page.keyboard.press('Enter');
await page.waitForTimeout(40);
const replaced = await page.evaluate(() => document.querySelector('[data-testid="tweetTextarea_1"]').textContent);
check('Enter replaces :fir with the emoji', replaced.endsWith('hot 🔥'), replaced);
await page.keyboard.type(' :joy:');
await page.waitForTimeout(80);
const exact = await page.evaluate(() => document.querySelector('[data-testid="tweetTextarea_1"]').textContent);
check(':joy: swaps to the emoji', exact.endsWith('😂'), exact);
// Ctrl+Enter with the inline list open is X's "post": the list closes, nothing is inserted.
await page.keyboard.type(' so :spark');
await page.keyboard.press('Control+Enter');
const ctrlEnter = await page.evaluate(() => ({ text: document.querySelector('[data-testid="tweetTextarea_1"]').textContent, list: !!document.querySelector('.bt-ep-ac') }));
check('Ctrl+Enter is not taken by the inline list', ctrlEnter.text.endsWith(':spark') && !ctrlEnter.list, JSON.stringify(ctrlEnter));
await collectCsp(); await context.close();
}
// No caret yet: the emoji goes at the end of the text.
{
const { page, context } = await openPage('home', 'dark', 'x', 1180);
await page.evaluate(() => {
document.activeElement?.blur();
window.getSelection().removeAllRanges();
document.querySelector('[data-testid="tweetTextarea_0"]').textContent = 'Tail';
});
await page.evaluate(() => document.querySelector('[data-composer="home"] [aria-label="Add emoji"]').click());
await page.waitForTimeout(60);
await page.evaluate(s => document.querySelector(s).shadowRoot.querySelector('.ep-cell').click(), HOST);
const tail = await page.evaluate(() => document.querySelector('[data-testid="tweetTextarea_0"]').textContent);
check('no caret yet: the emoji is appended after the text', tail.startsWith('Tail') && tail.length > 4, tail);
// Setting off: X's button is X's again, ours sits right after it and wears X's icon color.
await page.evaluate(() => window.__plugin.setReplaceX(false));
const own = await page.evaluate(() => {
const ours = document.querySelector('[data-composer="home"] .bt-ep-button');
const slot = ours?.closest('.bt-ep-slot');
const prev = slot?.previousElementSibling?.querySelector('[aria-label]')?.getAttribute('aria-label');
const xIcon = document.querySelector('[data-composer="home"] [aria-label="Add emoji"] svg');
return { prev, color: ours && getComputedStyle(ours).color, xColor: getComputedStyle(xIcon).color, size: ours && [ours.offsetWidth, ours.offsetHeight, ours.querySelector('svg').getBoundingClientRect().width] };
});
check("setting off: our button right after X's emoji button, same size and color as X's icons", own.prev === 'Add emoji' && own.color === own.xColor && own.size?.join() === '34,34,20', JSON.stringify(own));
await page.evaluate(() => { window.__xEmojiClicks = 0; });
await page.click(`[data-composer="home"] ${X_EMOJI}`);
const xAgain = await page.evaluate(s => ({ clicks: window.__xEmojiClicks, ours: !!document.querySelector(s) }), HOST);
check("setting off: X's emoji button is left to X", xAgain.clicks === 1 && !xAgain.ours, JSON.stringify(xAgain));
await page.click('[data-composer="home"] .bt-ep-button');
check('setting off: our button opens the picker', await page.evaluate(s => !!document.querySelector(s), HOST));
await page.keyboard.press('Escape');
await page.evaluate(() => window.__plugin.setReplaceX(true));
check('setting back on: our extra button is gone again', await page.evaluate(() => !document.querySelector('.bt-ep-button')));
// stop(): everything gone.
await page.click(`[data-composer="home"] ${X_EMOJI}`);
const stopped = await page.evaluate(() => {
window.__plugin.stop();
return { host: !!document.querySelector('wb-emoji-picker, .bt-ep-ac, .bt-ep-slot'), style: [...document.querySelectorAll('style')].filter(s => /.bt-ep-ac-opt/.test(s.textContent)).length };
});
await page.evaluate(() => { window.__xEmojiClicks = 0; });
await page.click(`[data-composer="home"] ${X_EMOJI}`);
const xAfterStop = await page.evaluate(() => window.__xEmojiClicks);
check("stop(): host, buttons and CSS removed, X's emoji button is X's again", !stopped.host && !stopped.style && xAfterStop === 1, JSON.stringify({ ...stopped, xAfterStop }));
await collectCsp(); await context.close();
}
// X's modal composer: the host goes inside the dialog (X's focus trap keeps the search box).
{
const { page, context } = await openPage('modal', 'dark', 'x', 1180);
await page.click(editorOf.modal);
await page.keyboard.type('In a modal');
await page.click(`[role="dialog"] ${X_EMOJI}`);
// After the open animation (it slides the card in by 4 px).
await page.waitForFunction(sel => !document.querySelector(sel)?.shadowRoot.querySelector('.ep-card').getAnimations().length, HOST);
const modal = await page.evaluate(s => {
const host = document.querySelector(s);
const card = host.shadowRoot.querySelector('.ep-card').getBoundingClientRect();
const button = document.querySelector('[role="dialog"] [aria-label="Add emoji"]').getBoundingClientRect();
return { inModal: !!host.closest('[aria-modal="true"]'), dx: Math.round(card.left - button.left), dy: Math.round(card.top - button.bottom), focused: host.shadowRoot.activeElement?.className };
}, HOST);
check('modal composer: host inside the dialog, card under the button despite the transform', modal.inModal && modal.dx === 0 && modal.dy === 8 && /ep-search-input/.test(modal.focused || ''), JSON.stringify(modal));
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
await page.waitForTimeout(40);
const modalText = await page.evaluate(() => document.querySelector('[role="dialog"] [data-testid="tweetTextarea_0"]').textContent);
check('modal composer: Enter inserts at the caret', modalText.startsWith('In a modal') && modalText.length > 'In a modal'.length, modalText);
await collectCsp(); await context.close();
}
// A stricter style-src than X's (inline <style> refused): the runtime and the picker still style
// everything, through constructed sheets (which a CSP does not govern).
{
const { page, context } = await openPage('home', 'dark', 'tokyo-night', 1180, HEIGHT, { csp: strictStyleCsp() });
await openFromX(page, 'home');
await page.evaluate(() => wayBetter.api.ui.toast('Styled under a strict CSP'));
await page.waitForTimeout(300);
const strict = await page.evaluate(sel => {
const root = document.querySelector(sel).shadowRoot;
const card = getComputedStyle(root.querySelector('.ep-card'));
const kit = document.querySelector('[data-wb-ui]')?.shadowRoot;
const toast = kit?.querySelector('.wb-toast');
const inline = document.querySelector('style[data-better-twttr-plugin="emoji-picker"]');
return {
refused: (window.__csp || []).some(v => v.startsWith('style-src')),
card: { width: card.width, bg: card.backgroundColor, adopted: root.adoptedStyleSheets.length, styleEls: root.querySelectorAll('style').length },
kit: { adopted: kit?.adoptedStyleSheets.length ?? 0, toastBg: toast ? getComputedStyle(toast).backgroundColor : null },
pluginCss: { inlineSheet: !!inline?.sheet, docAdopted: document.adoptedStyleSheets.length },
};
}, HOST);
check('strict style-src (inline <style> refused): card, UI kit and plugin page CSS styled via adopted sheets',
strict.refused && strict.card.width === '432px' && strict.card.bg !== 'rgba(0, 0, 0, 0)' && strict.card.adopted >= 1 && strict.card.styleEls === 0
&& strict.kit.adopted >= 1 && !!strict.kit.toastBg && strict.kit.toastBg !== 'rgba(0, 0, 0, 0)' && !strict.pluginCss.inlineSheet && strict.pluginCss.docAdopted >= 1,
JSON.stringify(strict));
await context.close();
}
// Reduced motion: no animation.
{
const rm = await openPage('home', 'light', 'paper', 900, 700, { reducedMotion: 'reduce' });
await openFromX(rm.page, 'home');
const anim = await rm.page.evaluate(s => getComputedStyle(document.querySelector(s).shadowRoot.querySelector('.ep-card')).animationName, HOST);
check('prefers-reduced-motion: no open animation', anim === 'none', anim);
await collectCsp(); await rm.context.close();
}
// Narrow panes: the same layout, narrower (fewer columns), clamped inside.
for (const width of [420, 360]) {
const nw = await openPage('home', 'dark', 'x', width);
await openFromX(nw.page, 'home');
await nw.page.waitForTimeout(450);
const fit = await nw.page.evaluate(s => {
const root = document.querySelector(s).shadowRoot;
const r = root.querySelector('.ep-card').getBoundingClientRect();
const row = root.querySelector('.ep-row').getBoundingClientRect();
const body = root.querySelector('.ep-scroll').getBoundingClientRect();
return { left: r.left, right: r.right, width: r.width, height: r.height, cols: root.querySelector('[role="grid"]').getAttribute('aria-colcount'), rail: root.querySelector('.ep-rail').offsetWidth, rowFits: row.right <= body.right - 4 };
}, HOST);
check(`${width} px pane: clamped inside with a margin, rail kept, fewer columns that fit`, fit.left >= 8 && fit.right <= width - 8 && fit.height === 440 && fit.rail === 48 && Number(fit.cols) >= 6 && fit.rowFits, JSON.stringify(fit));
await collectCsp(); await nw.context.close();
}
}
} finally {
await browser.close();
}
check('no page errors', errors.length === 0, errors.join(' | '));
check("no Content-Security-Policy violations under X's real CSP", violations.length === 0, violations.slice(0, 6).join(' | '));
await writeFile(join(out, `${label}-results.json`), JSON.stringify({ at: new Date().toISOString(), results, errors }, null, 2));
const failed = results.filter(r => !r.ok);
console.log(`\n${results.length - failed.length}/${results.length} checks passed; screenshots in ${out}`);
process.exit(failed.length && !shotsOnly ? 1 : 0);