wayBetter for X
This commit is contained in:
commit
434f193217
720 changed files with 147465 additions and 0 deletions
539
apps/desktop/scripts/emoji-picker-verify.mjs
Normal file
539
apps/desktop/scripts/emoji-picker-verify.mjs
Normal 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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue