213 lines
14 KiB
JavaScript
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);
|