waybetter-for-x/apps/desktop/scripts/ui-kit-verify.mjs
2026-10-01 06:01:13 +07:00

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);