410 lines
27 KiB
JavaScript
410 lines
27 KiB
JavaScript
// Browser check of the plugin UI kit (`wayBetter.api.ui`) and theme API with the BUILT X runtime
|
|
// (src-tauri/x-runtime.js) on an X-like fixture page served at https://x.com/ (Playwright route, no
|
|
// network). Checks keyboard/focus/stacking/cleanup behavior, theme inheritance (X default light,
|
|
// X Lights out, Night Ink, Paper) and three bundled plugins, and saves screenshots.
|
|
//
|
|
// node scripts/build-inject.mjs && node scripts/ui-kit-verify.mjs [--out ../../verification/ui-kit]
|
|
|
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { chromium } from 'playwright';
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const app = resolve(here, '..');
|
|
const outArg = process.argv.indexOf('--out');
|
|
const out = resolve(outArg > 0 ? process.argv[outArg + 1] : join(app, '../../verification/ui-kit'));
|
|
await mkdir(out, { recursive: true });
|
|
const runtime = await readFile(join(app, 'src-tauri/x-runtime.js'), 'utf8');
|
|
const pluginSource = name => readFile(join(app, 'src-tauri/bundled-plugins', name), 'utf8');
|
|
|
|
const PALETTES = {
|
|
light: { bg: 'rgb(255, 255, 255)', text: 'rgb(15, 20, 25)', muted: 'rgb(83, 100, 113)', border: 'rgb(239, 243, 244)' },
|
|
dark: { bg: 'rgb(0, 0, 0)', text: 'rgb(231, 233, 234)', muted: 'rgb(113, 118, 123)', border: 'rgb(47, 51, 54)' },
|
|
};
|
|
const day = 86_400_000;
|
|
const iso = ago => new Date(Date.now() - ago).toISOString();
|
|
|
|
function tweet(handle, name, text, ago, id) {
|
|
return `<article data-testid="tweet" tabindex="0" style="display:block;padding:12px 16px;border-bottom:1px solid var(--line)">
|
|
<div data-testid="User-Name"><a href="/${handle}"><span>${name}</span></a> <span>@${handle}</span></div>
|
|
<a href="/${handle}/status/${id}"><time datetime="${iso(ago)}">${ago > 30 * day ? 'Jan 3, 2023' : '2h'}</time></a>
|
|
<div data-testid="tweetText" style="margin:6px 0">${text}</div>
|
|
<div role="group" style="display:flex;gap:48px;color:var(--muted)">
|
|
<button data-testid="reply" onclick="window.__replies.push('${id}')" style="all:unset;cursor:pointer">💬 <span data-testid="app-text-transition-container">12</span></button>
|
|
<button data-testid="retweet" style="all:unset">🔁 <span data-testid="app-text-transition-container">3</span></button>
|
|
<button data-testid="like" style="all:unset">♡ <span data-testid="app-text-transition-container">48</span></button>
|
|
</div>
|
|
</article>`;
|
|
}
|
|
|
|
function fixture(mode) {
|
|
const p = PALETTES[mode];
|
|
return `<!doctype html><html><head><meta name="theme-color" content="${p.bg}"><title>X fixture</title>
|
|
<style>
|
|
:root { --line: ${p.border}; --muted: ${p.muted}; }
|
|
body { margin: 0; font: 15px/1.4 -apple-system, "Segoe UI", sans-serif; }
|
|
.r-text { color: ${p.text}; }
|
|
#react-root { display: flex; justify-content: center; gap: 24px; }
|
|
[data-testid="primaryColumn"] { width: 600px; border-left: 1px solid var(--line); border-right: 1px solid var(--line); min-height: 100vh; }
|
|
[data-testid="sidebarColumn"] { width: 350px; padding-top: 12px; }
|
|
header { padding: 14px 16px; font-weight: 800; font-size: 20px; border-bottom: 1px solid var(--line); }
|
|
section, aside { border: 1px solid var(--line); border-radius: 16px; padding: 12px 16px; margin-bottom: 16px; }
|
|
button.trigger { margin: 12px 16px; }
|
|
</style></head>
|
|
<body style="background-color: ${p.bg}; color: ${p.text}">
|
|
<div id="react-root" class="r-text">
|
|
<main role="main" data-testid="primaryColumn">
|
|
<header>Home</header>
|
|
<button class="trigger" id="trigger">Open (focus return target)</button>
|
|
${tweet('playfishies', 'playfishies', 'A fresh post from today. Replies go straight through.', 2 * 3600e3, '2103816196386852995')}
|
|
${tweet('jack', 'jack', 'just setting up my twttr', 3 * 365 * day, '20')}
|
|
${tweet('someone', 'Someone', 'A third post so the timeline has some depth to it.', 5 * 3600e3, '2103816196386852999')}
|
|
</main>
|
|
<div data-testid="sidebarColumn">
|
|
<section><h2 style="margin:0 0 8px;font-size:20px">What's happening</h2>
|
|
<div><div data-testid="trend">#wayBetter</div><div data-testid="trend">Tauri 3</div></div></section>
|
|
<aside><h2 style="margin:0 0 8px;font-size:20px">Who to follow</h2><div data-testid="UserCell">@playfishies</div></aside>
|
|
</div>
|
|
</div>
|
|
<script>window.__replies = []; window.__xKeys = []; document.addEventListener('keydown', e => window.__xKeys.push(e.key));</script>
|
|
</body></html>`;
|
|
}
|
|
|
|
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 context = await browser.newContext({ viewport: { width: 1180, height: 760 }, deviceScaleFactor: 1 });
|
|
const errors = [];
|
|
|
|
async function openPage(mode) {
|
|
const page = await context.newPage();
|
|
page.on('pageerror', error => errors.push(`${mode}: ${error.message}`));
|
|
await page.route('https://x.com/**', route => route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: fixture(mode) }));
|
|
await page.goto('https://x.com/home');
|
|
await page.addScriptTag({ content: runtime });
|
|
await page.waitForFunction(() => !!window.wayBetter?.api?.ui);
|
|
return page;
|
|
}
|
|
|
|
// Deep active element (through the kit's shadow root) as a short description.
|
|
const activeInfo = page => page.evaluate(() => {
|
|
let a = document.activeElement;
|
|
while (a?.shadowRoot?.activeElement) a = a.shadowRoot.activeElement;
|
|
return a ? { tag: a.nodeName, text: (a.textContent || '').trim().slice(0, 40), cls: a.className || '', id: a.id || '' } : null;
|
|
});
|
|
const kit = page => page.evaluateHandle(() => document.querySelector('wb-ui')?.shadowRoot);
|
|
const shot = async (page, name) => { await page.waitForTimeout(260); await page.screenshot({ path: join(out, `${name}.png`) }); };
|
|
|
|
async function showcase(page, label) {
|
|
// Toasts: every tone, one with an action, stacked.
|
|
await page.evaluate(() => {
|
|
const ui = wayBetter.api.ui;
|
|
ui.toast('Link copied');
|
|
ui.toast('Thread copied as Markdown', { tone: 'success' });
|
|
ui.toast('Muted Regex: 1 invalid pattern', { tone: 'warning', duration: 0 });
|
|
ui.toast('Post hidden', { tone: 'info', action: { label: 'Undo', onClick() {} }, duration: 0 });
|
|
});
|
|
await shot(page, `${label}-toasts`);
|
|
await page.evaluate(() => document.querySelectorAll('wb-ui').length && [...document.querySelector('wb-ui').shadowRoot.querySelectorAll('.wb-toast-close')].forEach(b => b.click()));
|
|
|
|
await page.evaluate(() => { window.__p = wayBetter.api.ui.alert({ title: 'Saved', message: 'Your note is kept on this device only.', tone: 'success' }); });
|
|
await shot(page, `${label}-alert`);
|
|
await page.keyboard.press('Enter');
|
|
|
|
await page.evaluate(() => { window.__p = wayBetter.api.ui.confirm({ title: 'Delete this note?', message: 'Your note about @jack will be removed from this device.', confirmLabel: 'Delete', danger: true }); });
|
|
await shot(page, `${label}-confirm-danger`);
|
|
await page.keyboard.press('Escape');
|
|
|
|
await page.evaluate(() => {
|
|
window.__p = wayBetter.api.ui.prompt({ title: 'Rename list', label: 'Name', value: 'x', placeholder: 'At least 3 characters', validate: v => v.trim().length < 3 ? 'Use at least 3 characters.' : null });
|
|
});
|
|
await page.keyboard.press('Enter');
|
|
await shot(page, `${label}-prompt-error`);
|
|
await page.keyboard.press('Escape');
|
|
|
|
// A settings dialog built like Hide Blue Checks', with a confirmation stacked on top.
|
|
await page.evaluate(() => {
|
|
const ui = wayBetter.api.ui;
|
|
const h = ui.h;
|
|
window.__d = ui.dialog({
|
|
title: 'Plugin settings',
|
|
message: 'Dialogs inherit the current theme: surface, text, border, button colors, corners and font.',
|
|
content: body => body.append(
|
|
h('label', { class: 'wb-check' }, h('input', { type: 'checkbox', checked: true }), h('span', null, 'Hide X Premium posts', h('small', null, 'Accounts you follow are kept.'))),
|
|
h('label', { class: 'wb-check' }, h('input', { type: 'checkbox' }), 'Also hide Business-verified accounts'),
|
|
h('div', { class: 'wb-divider' }),
|
|
h('label', { class: 'wb-field' }, h('span', { class: 'wb-label' }, 'Always show'), h('textarea', { class: 'wb-mono', rows: 3, placeholder: '@handle' })),
|
|
),
|
|
actions: [{ label: 'Cancel' }, { label: 'Save', tone: 'primary', default: true }],
|
|
});
|
|
});
|
|
await shot(page, `${label}-dialog`);
|
|
await page.evaluate(() => { window.__p = wayBetter.api.ui.confirm({ title: 'Discard changes?', message: 'You have unsaved settings.', confirmLabel: 'Discard' }); });
|
|
await shot(page, `${label}-stacked`);
|
|
await page.keyboard.press('Escape');
|
|
await page.keyboard.press('Escape');
|
|
}
|
|
|
|
try {
|
|
// ---------------------------------------------------------------- behavior (X default, light)
|
|
const page = await openPage('light');
|
|
const theme0 = await page.evaluate(() => wayBetter.api.theme.current());
|
|
check('theme.current() on X default light is X palette', theme0.siteDefault && theme0.colors.bg === '#ffffff' && theme0.colors.text === '#0f1419' && theme0.mode === 'light', JSON.stringify(theme0.colors));
|
|
check(':root carries --wb-* vars', await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--wb-accent').trim() === '#1d9bf0'));
|
|
check('no kit host before anything is shown', await page.evaluate(() => !document.querySelector('wb-ui')));
|
|
|
|
// confirm: focus, trap, Esc, focus return, page shortcuts isolated
|
|
await page.focus('#trigger');
|
|
await page.evaluate(() => { window.__p = wayBetter.api.ui.confirm({ title: 'Delete?', message: 'Gone for good.', danger: true }); });
|
|
let active = await activeInfo(page);
|
|
check('danger confirm focuses Cancel first', active?.text === 'Cancel', JSON.stringify(active));
|
|
const roles = await page.evaluate(() => {
|
|
const d = document.querySelector('wb-ui').shadowRoot.querySelector('.wb-dialog');
|
|
const byId = id => document.querySelector('wb-ui').shadowRoot.getElementById(id)?.textContent;
|
|
return { role: d.getAttribute('role'), modal: d.getAttribute('aria-modal'), label: byId(d.getAttribute('aria-labelledby')), desc: byId(d.getAttribute('aria-describedby')) };
|
|
});
|
|
check('confirm is an alertdialog, aria-modal, labelled and described', roles.role === 'alertdialog' && roles.modal === 'true' && roles.label === 'Delete?' && roles.desc === 'Gone for good.', JSON.stringify(roles));
|
|
await page.keyboard.press('Tab');
|
|
active = await activeInfo(page);
|
|
check('Tab from the last button wraps to the first', active?.text === 'Delete', JSON.stringify(active));
|
|
await page.keyboard.press('Shift+Tab');
|
|
active = await activeInfo(page);
|
|
check('Shift+Tab from the first wraps to the last', active?.text === 'Cancel', JSON.stringify(active));
|
|
await page.evaluate(() => { window.__xKeys.length = 0; });
|
|
await page.keyboard.press('j');
|
|
await page.keyboard.press('Escape');
|
|
const confirmResult = await page.evaluate(() => window.__p);
|
|
check('Esc resolves confirm(false)', confirmResult === false);
|
|
check("keys inside a dialog never reach X's shortcuts", await page.evaluate(() => window.__xKeys.length === 0), JSON.stringify(await page.evaluate(() => window.__xKeys)));
|
|
active = await activeInfo(page);
|
|
check('focus returns to the element that had it', active?.id === 'trigger', JSON.stringify(active));
|
|
|
|
// alert: Enter; confirm: backdrop click
|
|
await page.evaluate(() => { window.__p = wayBetter.api.ui.alert('Hello').then(() => 'done'); });
|
|
await page.keyboard.press('Enter');
|
|
check('Enter closes an alert', await page.evaluate(() => window.__p) === 'done');
|
|
await page.evaluate(() => { window.__p = wayBetter.api.ui.confirm('Continue?'); });
|
|
await page.mouse.click(20, 20);
|
|
check('backdrop click dismisses a confirmation (false)', await page.evaluate(() => window.__p) === false);
|
|
await page.evaluate(() => { window.__p = wayBetter.api.ui.confirm('Continue?'); });
|
|
await page.keyboard.press('Enter');
|
|
check('Enter accepts a normal confirmation (true)', await page.evaluate(() => window.__p) === true);
|
|
|
|
// prompt: validation, Enter, backdrop does not dismiss
|
|
await page.evaluate(() => { window.__p = wayBetter.api.ui.prompt({ title: 'Name', validate: v => v.length < 3 ? 'Too short' : null }); });
|
|
await page.keyboard.type('ab');
|
|
await page.keyboard.press('Enter');
|
|
const invalid = await page.evaluate(() => {
|
|
const root = document.querySelector('wb-ui').shadowRoot;
|
|
return { error: root.querySelector('.wb-error:not([hidden])')?.textContent, invalid: root.querySelector('input').getAttribute('aria-invalid'), open: !!root.querySelector('.wb-dialog') };
|
|
});
|
|
check('prompt shows the validation error and stays open', invalid.error === 'Too short' && invalid.invalid === 'true' && invalid.open, JSON.stringify(invalid));
|
|
await page.mouse.click(20, 20);
|
|
check('backdrop click does not dismiss a prompt', await page.evaluate(() => document.querySelector('wb-ui').shadowRoot.querySelectorAll('.wb-layer:not(.wb-closing)').length === 1));
|
|
await page.keyboard.type('c');
|
|
await page.keyboard.press('Enter');
|
|
check('prompt resolves the typed value on Enter', await page.evaluate(() => window.__p) === 'abc');
|
|
check("typing in a prompt never reached X's shortcuts", await page.evaluate(() => window.__xKeys.length === 0));
|
|
|
|
// stacking: dialog + confirm on top; lower one inert; Esc closes only the top; focus goes back down
|
|
await page.evaluate(() => {
|
|
const ui = wayBetter.api.ui;
|
|
window.__d = ui.dialog({ title: 'Lower', content: ui.h('input', { type: 'text', id: 'lowfield' }), actions: [{ label: 'Close' }] });
|
|
});
|
|
await page.keyboard.type('x');
|
|
await page.evaluate(() => { window.__p = wayBetter.api.ui.confirm('On top?'); });
|
|
await page.waitForTimeout(300);
|
|
check('closed dialogs leave the DOM after their exit animation', await page.evaluate(() => document.querySelector('wb-ui').shadowRoot.querySelectorAll('.wb-layer.wb-closing').length) === 0);
|
|
const stack = await page.evaluate(() => [...document.querySelector('wb-ui').shadowRoot.querySelectorAll('.wb-layer:not(.wb-closing)')].map(l => l.hasAttribute('inert')));
|
|
check('stacked: the lower dialog is inert, the top one is not', JSON.stringify(stack) === '[true,false]', JSON.stringify(stack));
|
|
await page.keyboard.press('Escape');
|
|
await page.waitForTimeout(200);
|
|
active = await activeInfo(page);
|
|
check('Esc closes only the top dialog; focus returns into the lower one', await page.evaluate(() => window.__d.open) && active?.id === 'lowfield', JSON.stringify(active));
|
|
await page.keyboard.press('Escape');
|
|
|
|
// scope cleanup and host removal; legacy api.toast
|
|
const cleanup = await page.evaluate(async () => {
|
|
const scope = wayBetter.api.scope();
|
|
const d = scope.ui.dialog({ title: 'Scoped' });
|
|
scope.ui.toast('scoped toast', { duration: 0 });
|
|
const p = scope.ui.confirm('scoped confirm');
|
|
scope.dispose();
|
|
const confirmed = await p;
|
|
await new Promise(r => setTimeout(r, 400));
|
|
return { open: d.open, confirmed, host: !!document.querySelector('wb-ui') };
|
|
});
|
|
check('scope.dispose() closes the scope\'s dialogs/toasts, pending confirm resolves false, host removed', !cleanup.open && cleanup.confirmed === false && !cleanup.host, JSON.stringify(cleanup));
|
|
// An async action that asks a stacked confirmation: after Cancel, focus is back inside the first dialog.
|
|
await page.evaluate(() => {
|
|
const ui = wayBetter.api.ui;
|
|
window.__d = ui.dialog({ title: 'Note', content: ui.h('textarea', { id: 'note' }), actions: [
|
|
{ label: 'Delete', tone: 'plain', autoFocus: true, onClick: async () => (await ui.confirm({ title: 'Delete?', danger: true })) ? undefined : false },
|
|
{ label: 'Save', tone: 'primary' },
|
|
] });
|
|
});
|
|
await page.keyboard.press('Enter');
|
|
await page.waitForTimeout(60);
|
|
await page.keyboard.press('Escape');
|
|
await page.waitForTimeout(200);
|
|
active = await activeInfo(page);
|
|
check("after an async action's confirm is cancelled, focus is back on its button and Esc still works", active?.text === 'Delete', JSON.stringify(active));
|
|
await page.keyboard.press('Escape');
|
|
check('...and Esc then closes the first dialog', await page.evaluate(() => !window.__d.open));
|
|
|
|
// api.tweet reads the post's own time on a post page, where a quoted post's time comes first.
|
|
const detail = await page.evaluate(() => {
|
|
const article = document.createElement('article');
|
|
article.setAttribute('data-testid', 'tweet');
|
|
const quote = document.createElement('div');
|
|
quote.setAttribute('role', 'link');
|
|
const qa = document.createElement('a'); qa.href = '/other/status/111';
|
|
const qt = document.createElement('time'); qt.setAttribute('datetime', '2020-01-01T00:00:00.000Z'); qa.append(qt); quote.append(qa);
|
|
const own = document.createElement('a'); own.href = '/jack/status/222';
|
|
const ot = document.createElement('time'); ot.setAttribute('datetime', '2026-09-28T10:00:00.000Z'); own.append(ot);
|
|
article.append(quote, own);
|
|
document.body.append(article);
|
|
const info = wayBetter.api.tweet(article);
|
|
article.remove();
|
|
return { id: info?.id, handle: info?.handle, time: info?.time };
|
|
});
|
|
check("api.tweet uses the post's own time, not a quoted post's", detail.id === '222' && detail.handle === 'jack' && detail.time === '2026-09-28T10:00:00.000Z', JSON.stringify(detail));
|
|
|
|
await page.evaluate(() => wayBetter.api.toast('Legacy toast', 1500));
|
|
check('legacy api.toast(message, ms) shows a kit toast', await page.evaluate(() => document.querySelector('wb-ui')?.shadowRoot.querySelector('.wb-toast')?.getAttribute('role') === 'status'));
|
|
await page.evaluate(() => { for (let i = 0; i < 7; i++) wayBetter.api.ui.toast(`t${i}`, { duration: 0 }); });
|
|
check('at most 4 toasts stack', await page.evaluate(() => document.querySelector('wb-ui').shadowRoot.querySelectorAll('.wb-toast:not(.wb-closing)').length) === 4);
|
|
await page.evaluate(() => [...document.querySelector('wb-ui').shadowRoot.querySelectorAll('.wb-toast-close')].forEach(b => b.click()));
|
|
|
|
// live theme change while a dialog is open
|
|
await page.evaluate(() => { window.__d = wayBetter.api.ui.dialog({ title: 'Live theme' }); });
|
|
const before = await page.evaluate(() => getComputedStyle(document.querySelector('wb-ui').shadowRoot.querySelector('.wb-dialog')).backgroundColor);
|
|
const seen = await page.evaluate(() => new Promise(resolve => {
|
|
const off = wayBetter.api.theme.onChange(t => { off(); resolve(t.id); });
|
|
wayBetter.theme.apply({ preset: 'night-ink' });
|
|
}));
|
|
await page.waitForTimeout(100);
|
|
const after = await page.evaluate(() => getComputedStyle(document.querySelector('wb-ui').shadowRoot.querySelector('.wb-dialog')).backgroundColor);
|
|
const nightBg = await page.evaluate(() => wayBetter.api.theme.current().colors.bg);
|
|
check('theme.onChange fires and an open dialog repaints live', seen === 'night-ink' && before !== after, `${before} -> ${after} (theme bg ${nightBg})`);
|
|
await page.keyboard.press('Escape');
|
|
|
|
// X's own CSS cannot reach the kit
|
|
await page.addStyleTag({ content: 'button, h2, p, div { color: red !important; font-family: serif !important; background: yellow !important; }' });
|
|
await page.evaluate(() => { window.__d = wayBetter.api.ui.dialog({ title: 'Isolated', message: 'Page CSS stays out', actions: [{ label: 'OK', tone: 'primary' }] }); });
|
|
const isolated = await page.evaluate(() => {
|
|
const root = document.querySelector('wb-ui').shadowRoot;
|
|
const title = getComputedStyle(root.querySelector('.wb-title'));
|
|
return { color: title.color, bg: getComputedStyle(root.querySelector('.wb-dialog')).backgroundColor };
|
|
});
|
|
check("the site's CSS does not restyle the kit (shadow DOM)", isolated.color !== 'rgb(255, 0, 0)' && isolated.bg !== 'rgb(255, 255, 0)', JSON.stringify(isolated));
|
|
await page.keyboard.press('Escape');
|
|
await page.close();
|
|
|
|
// reduced motion
|
|
const reduced = await browser.newContext({ viewport: { width: 900, height: 600 }, reducedMotion: 'reduce' });
|
|
const rpage = await reduced.newPage();
|
|
await rpage.route('https://x.com/**', route => route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: fixture('light') }));
|
|
await rpage.goto('https://x.com/home');
|
|
await rpage.addScriptTag({ content: runtime });
|
|
await rpage.waitForFunction(() => !!window.wayBetter?.api?.ui);
|
|
await rpage.evaluate(() => wayBetter.api.ui.dialog({ title: 'Still' }));
|
|
const anim = await rpage.evaluate(() => getComputedStyle(document.querySelector('wb-ui').shadowRoot.querySelector('.wb-dialog')).animationName);
|
|
check('prefers-reduced-motion: no dialog animation', anim === 'none', anim);
|
|
await reduced.close();
|
|
|
|
// ---------------------------------------------------------------- themes: screenshots of every component
|
|
for (const [label, mode, preset] of [['x-light', 'light', 'x'], ['x-lightsout', 'dark', 'x'], ['night-ink', 'dark', 'night-ink'], ['paper', 'light', 'paper']]) {
|
|
const tpage = await openPage(mode);
|
|
await tpage.evaluate(p => wayBetter.theme.apply({ preset: p }), preset);
|
|
await tpage.waitForTimeout(150);
|
|
const t = await tpage.evaluate(() => wayBetter.api.theme.current());
|
|
check(`${label}: theme.current()`, t.id === preset && (preset !== 'x' || t.mode === (mode === 'light' ? 'light' : 'dark')), `${t.id} ${t.mode} bg ${t.colors.bg} accent ${t.colors.accent} radius ${t.radius}`);
|
|
await showcase(tpage, label);
|
|
await tpage.close();
|
|
}
|
|
|
|
// ---------------------------------------------------------------- bundled plugins on the kit
|
|
const ppage = await openPage('dark');
|
|
await ppage.evaluate(() => wayBetter.theme.apply({ preset: 'x' }));
|
|
const load = async name => ppage.evaluate(([n, source]) => {
|
|
const module = { exports: {} };
|
|
new Function('module', 'exports', source)(module, module.exports);
|
|
const plugin = new module.exports();
|
|
plugin.start();
|
|
(window.__plugins ||= {})[n] = plugin;
|
|
}, [name, await pluginSource(`${name}.plugin.js`)]);
|
|
for (const name of ['hide-blue-checks', 'focus-mode', 'reply-guard', 'muted-regex']) await load(name);
|
|
|
|
await ppage.keyboard.press('Alt+Shift+H');
|
|
await shot(ppage, 'plugin-hide-blue-checks-settings');
|
|
check('Hide Blue Checks settings open in a kit dialog', await ppage.evaluate(() => document.querySelector('wb-ui')?.shadowRoot.querySelector('.wb-title')?.textContent === 'Hide Blue Checks'));
|
|
await ppage.keyboard.press('Escape');
|
|
|
|
await ppage.keyboard.press('Alt+Shift+M');
|
|
await ppage.keyboard.type('(broken');
|
|
await shot(ppage, 'plugin-muted-regex-editor');
|
|
await ppage.keyboard.press('Escape');
|
|
|
|
const focusState = () => ppage.evaluate(() => ({
|
|
trends: getComputedStyle(document.querySelector('[data-testid="sidebarColumn"] section')).display,
|
|
who: getComputedStyle(document.querySelector('[data-testid="sidebarColumn"] aside')).display,
|
|
count: getComputedStyle(document.querySelector('[role="group"] [data-testid="app-text-transition-container"]')).visibility,
|
|
toast: document.querySelector('wb-ui')?.shadowRoot.querySelector('.wb-toast:not(.wb-closing) .wb-toast-text')?.textContent ?? null,
|
|
}));
|
|
const started = await focusState();
|
|
check('Focus Mode starts on: trends, who to follow and counts hidden', started.trends === 'none' && started.who === 'none' && started.count === 'hidden', JSON.stringify(started));
|
|
await ppage.keyboard.press('Alt+Shift+F');
|
|
await ppage.waitForTimeout(120);
|
|
const off = await focusState();
|
|
check('Alt+Shift+F right after closing a dialog turns Focus Mode off (toast says so)', off.trends !== 'none' && off.count === 'visible' && off.toast === 'Focus Mode off', JSON.stringify(off));
|
|
await ppage.keyboard.press('Alt+Shift+F');
|
|
await ppage.waitForTimeout(120);
|
|
const on = await focusState();
|
|
check('Alt+Shift+F again turns it back on', on.trends === 'none' && on.count === 'hidden' && on.toast === 'Focus Mode on', JSON.stringify(on));
|
|
await shot(ppage, 'plugin-focus-mode-on');
|
|
await ppage.evaluate(() => { const root = document.querySelector('wb-ui')?.shadowRoot; root?.querySelector('.wb-toast-action')?.click(); });
|
|
await shot(ppage, 'plugin-focus-mode-settings');
|
|
await ppage.keyboard.press('Escape');
|
|
|
|
await ppage.evaluate(() => document.querySelectorAll('article')[1].querySelector('[data-testid="reply"]').click());
|
|
await shot(ppage, 'plugin-reply-guard-confirm');
|
|
const guarded = await ppage.evaluate(() => ({ replies: [...window.__replies], title: document.querySelector('wb-ui')?.shadowRoot.querySelector('.wb-title')?.textContent, message: document.querySelector('wb-ui')?.shadowRoot.querySelector('.wb-message')?.textContent }));
|
|
check('Reply Guard holds the reply to a 3-year-old post and asks', guarded.replies.length === 0 && guarded.title === 'Reply to this post?' && /3 years old/.test(guarded.message), JSON.stringify(guarded));
|
|
await ppage.evaluate(() => [...document.querySelector('wb-ui').shadowRoot.querySelectorAll('.wb-btn')].find(b => b.textContent === 'Reply anyway').click());
|
|
await ppage.waitForTimeout(50);
|
|
check('"Reply anyway" lets X\'s own reply click through once', JSON.stringify(await ppage.evaluate(() => window.__replies)) === '["20"]');
|
|
await ppage.evaluate(() => document.querySelectorAll('article')[0].querySelector('[data-testid="reply"]').click());
|
|
check('a fresh post replies without asking', JSON.stringify(await ppage.evaluate(() => window.__replies)) === '["20","2103816196386852995"]');
|
|
|
|
// stop() undoes everything
|
|
await ppage.evaluate(() => { wayBetter.api.ui.toast('x', { duration: 0 }); });
|
|
await ppage.evaluate(() => window.__plugins['reply-guard'].openSettings());
|
|
const stopped = await ppage.evaluate(async () => {
|
|
for (const plugin of Object.values(window.__plugins)) plugin.stop();
|
|
document.querySelector('wb-ui')?.shadowRoot.querySelectorAll('.wb-toast-close').forEach(b => b.click());
|
|
await new Promise(r => setTimeout(r, 400));
|
|
document.querySelectorAll('article')[1].querySelector('[data-testid="reply"]').click();
|
|
return {
|
|
styles: [...document.querySelectorAll('style')].filter(s => /focus-mode|hide-blue|muted-regex/.test(s.dataset.betterTwttrPlugin || '')).length,
|
|
trends: getComputedStyle(document.querySelector('[data-testid="sidebarColumn"] section')).display,
|
|
host: !!document.querySelector('wb-ui'),
|
|
replies: window.__replies.length,
|
|
};
|
|
});
|
|
check('stop(): plugin CSS gone, dialogs closed, kit host removed, reply no longer guarded', stopped.styles === 0 && stopped.trends !== 'none' && !stopped.host && stopped.replies === 3, JSON.stringify(stopped));
|
|
await ppage.close();
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
|
|
check('no page errors', errors.length === 0, errors.join(' | '));
|
|
await writeFile(join(out, '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 ? 1 : 0);
|