waybetter-for-x/apps/desktop/scripts/column-e2e.mjs
2026-10-01 06:01:13 +07:00

213 lines
14 KiB
JavaScript

// Power User Mode, page side: the built X runtime (src-tauri/x-runtime.js) and tab bridge
// (src-tauri/tab-bridge.js) on an X-shaped fixture served at https://x.com/home (Playwright route;
// no live X), at deck column widths 320/360/400. With a fake Tauri IPC that records calls, it checks:
// X's chrome is hidden and the content fills the column, filters (keyword, /regex/, replies,
// reposts, media only), unread counting (baseline, new posts above, seen on scroll, Alt+R), the
// unread number reaching `tab_report`, deck keys forwarded only outside text fields, and that the
// column module makes no request of its own. Screenshots: verification/deck/fixture/.
//
// node scripts/build-inject.mjs && node scripts/column-e2e.mjs
import { chromium } from 'playwright';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const app = fileURLToPath(new URL('..', import.meta.url));
const out = join(app, '../../verification/deck/fixture');
await mkdir(out, { recursive: true });
const runtime = await readFile(join(app, 'src-tauri/x-runtime.js'), 'utf8');
const bridge = await readFile(join(app, '../../waybetter/crates/waybetter-host/tab-bridge.js'), 'utf8');
const failures = [];
const check = (label, ok, detail = '') => {
console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${detail ? ` (${detail})` : ''}`);
if (!ok) failures.push(label);
};
let nextId = 9000;
const post = ({ id = String(nextId++), name = 'Lena Ortiz', handle = 'lena', text = 'A post', reply = false, repost = false, media = false } = {}) => `
<div data-testid="cellInnerDiv" style="border-bottom: 1px solid #2f3336;">
<article data-testid="tweet" tabindex="0" style="padding: 12px 16px; display: flex; gap: 10px;">
<div style="width: 40px; height: 40px; border-radius: 50%; background: #333; flex: none;"></div>
<div style="flex: 1; min-width: 0;">
${repost ? `<div data-testid="socialContext" style="color:#71767b;font-size:13px">Maya reposted</div>` : ''}
<div data-testid="User-Name" style="display:flex;gap:4px;color:#e7e9ea;font-weight:700">${name} <span style="color:#71767b;font-weight:400">@${handle} · <a href="/${handle}/status/${id}"><time datetime="2026-09-28T10:00:00Z">2h</time></a></span></div>
${reply ? `<div style="color:#71767b;font-size:14px"><div>Replying to <a href="/someone">@someone</a></div></div>` : ''}
<div data-testid="tweetText" style="color:#e7e9ea;font-size:15px;line-height:20px">${text}</div>
${media ? `<div data-testid="tweetPhoto" style="height: 120px; margin-top: 8px; border-radius: 16px; background: linear-gradient(135deg,#1d9bf0,#7856ff);"></div>` : ''}
<div role="group" style="display:flex;justify-content:space-between;color:#71767b;margin-top:8px"><span>1</span><span>2</span><span>3</span></div>
</div>
</article>
</div>`;
const FIXTURE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Home / X</title>
<style>body{margin:0;background:#000;color:#e7e9ea;font:15px system-ui} a{color:#1d9bf0} .row{display:flex;justify-content:center;min-height:100vh}</style></head>
<body><div id="react-root"><div class="row">
<header role="banner" style="width:88px;flex:none;border-right:1px solid #2f3336"><nav><a data-testid="AppTabBar_Home_Link" href="/home">H</a><div data-testid="AppTabBar_More_Menu">More</div></nav>
<a href="/compose/post" data-testid="SideNav_NewTweet_Button">Post</a></header>
<main role="main" style="display:flex;flex:1;align-items:flex-start;justify-content:center"><div style="display:flex;width:990px"><div style="display:flex;width:990px">
<div data-testid="primaryColumn" style="width:600px;max-width:600px;border-left:1px solid #2f3336;border-right:1px solid #2f3336">
<div aria-label="Home timeline">
<div style="position:sticky;top:0;background:rgba(0,0,0,.85)"><div><div style="height:53px">Home</div><div role="tablist" style="display:flex"><div role="tab" aria-selected="true">For you</div><div role="tab" aria-selected="false">Following</div></div></div></div>
<div><div><div><div><div><label data-testid="tweetTextarea_0_label">What's happening?</label></div></div></div></div></div>
<div id="timeline">${[
post({ id: '101', text: 'Good morning timeline' }),
post({ id: '102', text: 'Huge GIVEAWAY, retweet to win', name: 'Spam Bot', handle: 'spam' }),
post({ id: '103', text: 'gm frens' }),
post({ id: '104', text: 'A reply in the timeline', reply: true }),
post({ id: '105', text: 'Reposted thing', repost: true }),
post({ id: '106', text: 'Photo post', media: true }),
...Array.from({ length: 10 }, (_, i) => post({ id: String(110 + i), text: `Older post ${i}` })),
].join('')}</div>
</div>
</div>
<div data-testid="sidebarColumn" style="width:350px;margin-left:30px">Trends</div>
</div></div></main></div></div><div id="layers"></div>
<script>window.__post = ${post.toString().replace(/nextId\+\+/, 'Math.floor(Math.random()*1e9)')};</script>
</body></html>`;
const browser = await chromium.launch();
const errors = [];
async function openColumn(width, config) {
const context = await browser.newContext({ viewport: { width, height: 800 }, deviceScaleFactor: 1 });
const page = await context.newPage();
page.on('pageerror', error => errors.push(`${width}: ${error.message}`));
const requests = [];
page.on('request', request => requests.push(request.url()));
await page.route('https://x.com/**', route => route.request().resourceType() === 'document'
? route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: FIXTURE })
: route.fulfill({ status: 204, body: '' }));
// A fake Tauri IPC that records what the page asks native code for.
await page.addInitScript(() => {
window.__calls = [];
window.__TAURI_INTERNALS__ = { invoke: (cmd, args) => { window.__calls.push({ cmd, args }); return Promise.resolve(null); } };
});
await page.addInitScript({ content: bridge });
if (config) await page.addInitScript(value => { window.__btColumn = value; }, config);
await page.addInitScript({ content: runtime });
await page.goto('https://x.com/home');
await page.waitForFunction(() => !!window.BetterTwttr?.column);
await page.waitForTimeout(400);
return { page, context, requests };
}
const CONFIG = {
url: 'https://x.com/home', kind: 'home', density: 'compact',
filters: { terms: ['giveaway', '/^gm\\b/'], hideReplies: false, hideReposts: false, mediaOnly: false },
flags: { disableVideoAutoplay: true }, unread: true, readSeq: 0, jumpSeq: 0,
};
const shown = (page, selector) => page.$eval(selector, el => { const box = el.getBoundingClientRect(); return getComputedStyle(el).display !== 'none' && box.width > 0 && box.height > 0; }).catch(() => false);
const visibleIds = page => page.$$eval('article[data-testid="tweet"]', els => els.filter(el => el.getBoundingClientRect().height > 0).map(el => el.querySelector('a[href*="/status/"]').getAttribute('href').split('/').pop()));
for (const width of [320, 360, 400]) {
const { page, context, requests } = await openColumn(width, CONFIG);
check(`${width}: column mode on`, await page.evaluate(() => document.documentElement.hasAttribute('data-wb-column')));
check(`${width}: X's rail, side column and inline composer hidden`,
!(await shown(page, 'header[role="banner"]')) && !(await shown(page, '[data-testid="sidebarColumn"]')) && !(await shown(page, '[data-testid="tweetTextarea_0_label"]')));
const primary = await page.$eval('[data-testid="primaryColumn"]', el => { const b = el.getBoundingClientRect(); return [Math.round(b.left), Math.round(b.width)]; });
const inner = await page.evaluate(() => document.documentElement.clientWidth);
check(`${width}: content fills the column`, primary[0] === 0 && Math.abs(primary[1] - inner) <= 1, `${primary} of ${inner}`);
check(`${width}: the page title row is hidden on the column's own page, its tabs stay`,
await page.$eval('[data-testid="primaryColumn"] [role="tablist"]', el => el.getBoundingClientRect().height > 0)
&& await page.$eval('[data-testid="primaryColumn"] > div > div:first-child > div:first-child > div:first-child', el => getComputedStyle(el).display === 'none'));
const ids = await visibleIds(page);
check(`${width}: keyword and /regex/ filters hide posts`, !ids.includes('102') && !ids.includes('103') && ids.includes('101'), ids.slice(0, 6).join(','));
await page.screenshot({ path: join(out, `column-${width}.png`) });
if (width === 360) {
// Replies, reposts, media only.
await page.evaluate(config => window.BetterTwttr.column.set(config), { ...CONFIG, filters: { terms: [], hideReplies: true, hideReposts: true, mediaOnly: false } });
await page.waitForTimeout(200);
let now = await visibleIds(page);
check('hide replies and reposts', !now.includes('104') && !now.includes('105') && now.includes('102'), now.slice(0, 6).join(','));
await page.evaluate(config => window.BetterTwttr.column.set(config), { ...CONFIG, filters: { terms: [], hideReplies: false, hideReposts: false, mediaOnly: true } });
await page.waitForTimeout(200);
now = await visibleIds(page);
check('media only', now.length === 1 && now[0] === '106', now.join(','));
await page.evaluate(config => window.BetterTwttr.column.set(config), CONFIG);
await page.waitForTimeout(200);
// Unread: scrolled down, two new posts arrive on top.
await page.evaluate(() => window.scrollTo(0, 900));
await page.waitForTimeout(200);
await page.evaluate(() => {
const timeline = document.getElementById('timeline');
timeline.insertAdjacentHTML('afterbegin', window.__post({ id: '201', text: 'Brand new post' }) + window.__post({ id: '202', text: 'Another new one' }));
window.scrollBy(0, timeline.firstElementChild.offsetHeight * 2);
});
await page.waitForTimeout(700);
const unread = await page.evaluate(() => window.BetterTwttr.column.unread());
check('two new posts above count as unread', unread === 2, `${unread}`);
const reports = await page.evaluate(() => window.__calls.filter(c => c.cmd === 'tab_report' && c.args?.report && 'unread' in c.args.report).map(c => c.args.report.unread));
check('the unread number reaches tab_report (a number only)', reports.at(-1) === 2, reports.join(','));
check('unread posts are marked', await page.$$eval('[data-wb-unread]', els => els.length) === 2);
await page.screenshot({ path: join(out, 'column-360-unread.png') });
await page.keyboard.press('Alt+U');
await page.waitForTimeout(300);
const top = await page.$eval('article a[href$="/status/202"]', el => Math.round(el.closest('[data-testid="cellInnerDiv"]').getBoundingClientRect().top));
check('Alt+U jumps to the next unread post', top >= -2 && top < 60, `${top}`);
await page.waitForTimeout(700);
const afterJump = await page.evaluate(() => window.BetterTwttr.column.unread());
check('posts scrolled into view are read', afterJump < 2, `${afterJump}`);
await page.evaluate(() => window.scrollTo(0, 900));
await page.evaluate(() => document.getElementById('timeline').insertAdjacentHTML('afterbegin', window.__post({ id: '203', text: 'Third' })));
await page.waitForTimeout(500);
await page.keyboard.press('Alt+R');
await page.waitForTimeout(500);
check('Alt+R marks the column read', await page.evaluate(() => window.BetterTwttr.column.unread()) === 0);
check('readSeq from the shell marks read too', await page.evaluate(config => {
document.getElementById('timeline').insertAdjacentHTML('afterbegin', window.__post({ id: '204', text: 'Fourth' }));
return new Promise(resolve => setTimeout(() => { window.BetterTwttr.column.set({ ...config, readSeq: 1 }); resolve(window.BetterTwttr.column.unread() === 0); }, 400));
}, CONFIG));
// Deck keys: forwarded by the tab bridge outside text fields only.
await page.evaluate(() => { window.__calls.length = 0; });
await page.keyboard.press(']');
await page.keyboard.press('Alt+2');
await page.keyboard.press('Alt+PageDown');
let keys = await page.evaluate(() => window.__calls.filter(c => c.cmd === 'tab_shortcut').map(c => c.args.shortcut));
check('deck keys are forwarded from a column', keys.join() === 'column-next,column-2,column-page-next', keys.join());
await page.evaluate(() => {
const input = document.createElement('div');
input.contentEditable = 'true';
input.id = 'fake-composer';
document.body.prepend(input);
input.focus();
window.__calls.length = 0;
});
await page.keyboard.press(']');
await page.keyboard.press('[');
keys = await page.evaluate(() => window.__calls.filter(c => c.cmd === 'tab_shortcut').map(c => c.args.shortcut));
const typed = await page.$eval('#fake-composer', el => el.textContent);
check('bare deck keys are ignored while typing', keys.length === 0 && typed === '][', `${keys.join()} typed "${typed}"`);
// Back to an ordinary tab: everything is undone.
await page.evaluate(() => window.BetterTwttr.column.set(null));
await page.waitForTimeout(200);
check('column off restores X', !(await page.evaluate(() => document.documentElement.hasAttribute('data-wb-column'))) && await shown(page, 'header[role="banner"]') && (await visibleIds(page)).includes('102'));
}
const own = requests.filter(url => !url.startsWith('https://x.com/home'));
check(`${width}: the column module made no request`, own.length === 0, own.slice(0, 3).join(' '));
await context.close();
}
// Keys in an ordinary (non-column) X tab: only Ctrl+Shift+D is a deck key there.
{
const { page, context } = await openColumn(900, null);
await page.evaluate(() => { window.__calls.length = 0; });
await page.keyboard.press(']');
await page.keyboard.press('Alt+2');
await page.keyboard.press('Control+Shift+D');
const keys = await page.evaluate(() => window.__calls.filter(c => c.cmd === 'tab_shortcut').map(c => c.args.shortcut));
check('ordinary tabs forward only the deck toggle', keys.join() === 'deck-toggle', keys.join());
await context.close();
}
await browser.close();
await writeFile(join(out, 'column-e2e.json'), JSON.stringify({ failures, errors }, null, 1));
console.log(errors.length ? `page errors:\n${errors.join('\n')}` : 'no page errors');
console.log(failures.length ? `${failures.length} FAILED` : 'all column checks passed');
process.exit(failures.length ? 1 : 0);