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

161 lines
9.3 KiB
JavaScript

// Power User Mode, shell harness E2E: the real deck UI (DeckView, AddColumnDialog, menus) in the
// `/?shell` preview (native commands mocked in memory, no webviews) against a running `vite dev`.
// Checks add / reorder / remove, paging, focus keys (and that they are ignored while typing), the
// column configs sent (column_configure) and the tabs_sync payload; screenshots go to
// verification/deck/harness/. Not a native or live X test.
//
// node scripts/deck-check.mjs [baseUrl]
import { chromium } from 'playwright';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const base = process.argv[2] ?? 'http://127.0.0.1:1420';
const out = join(import.meta.dirname, '..', '..', '..', 'verification', 'deck', 'harness');
mkdirSync(out, { recursive: true });
const failures = [];
const check = (label, ok, detail = '') => {
console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${detail ? ` (${detail})` : ''}`);
if (!ok) failures.push(label);
};
const browser = await chromium.launch();
const errors = [];
const open = async (query, viewport = { width: 1600, height: 900 }) => {
const page = await browser.newPage({ viewport, deviceScaleFactor: 1, reducedMotion: 'reduce' });
page.on('pageerror', error => errors.push(`${query}: ${error.message}`));
page.on('console', message => { if (message.type() === 'error') errors.push(`${query}: ${message.text()}`); });
await page.goto(`${base}/?shell&tabs=3&${query}`);
await page.waitForSelector('.bt-titlebar');
await page.waitForTimeout(700);
return page;
};
const harness = page => page.evaluate(() => {
const { state } = window.__btHarness;
return { sync: state.sync, columns: state.columns };
});
const columnTitles = page => page.$$eval('.bt-col .bt-col-titles strong', els => els.map(el => el.textContent));
const shot = (page, name) => page.screenshot({ path: join(out, `${name}.png`) });
// 1. A deck of five columns at 1600 px: four on the first page, one parked.
let page = await open('deck=5&open=deck&unread');
await page.waitForSelector('.bt-col');
await page.waitForTimeout(300);
let titles = await columnTitles(page);
check('first page shows four whole columns', titles.length === 4, titles.join(', '));
let state = await harness(page);
let boxes = state.sync.filter(item => item.role === 'column');
check('tabs_sync carries all five columns as role "column"', boxes.length === 5, `${boxes.length}`);
check('four visible, one parked off-page', boxes.filter(b => b.visible).length === 4 && boxes.filter(b => b.parked).length === 1,
boxes.map(b => `${b.id}:${b.visible ? 'v' : '-'}${b.parked ? 'p' : ''}`).join(' '));
check('columns are never nav-redundant', boxes.every(b => b.navRedundant === false));
const urls = Object.fromEntries(Object.entries(state.columns).map(([id, config]) => [id, config.url]));
check('every column got its config with the right x.com page', urls.k0 === 'https://x.com/home' && urls.k1 === 'https://x.com/notifications'
&& urls.k2 === 'https://x.com/notifications/mentions' && urls.k3 === 'https://x.com/search?q=from%3Aplayfishies%20filter%3Amedia&f=live'
&& urls.k4 === 'https://x.com/playfishies', JSON.stringify(urls));
check('default column flags: autoplay off, promoted hidden', state.columns.k0?.flags?.disableVideoAutoplay === true && state.columns.k0?.flags?.hidePromotedPosts === true);
check('filters travel with the config', JSON.stringify(state.columns.k1?.filters?.terms) === '["giveaway"]');
const visible = boxes.filter(b => b.visible).sort((a, b) => a.x - b.x);
const rowRight = await page.$eval('.bt-deck-row', el => Math.round(el.getBoundingClientRect().right));
check('visible columns do not overlap and fill the row', visible.every((b, i) => i === 0 || b.x >= visible[i - 1].x + visible[i - 1].width)
&& Math.abs((visible.at(-1).x + visible.at(-1).width) - rowRight) <= 1, `${visible.map(b => `${b.x}+${b.width}`).join(' ')} row ends ${rowRight}`);
const badges = await page.$$eval('.bt-col-unread', els => els.map(el => el.textContent));
check('unread badges show the counts (capped 999+)', JSON.stringify(badges) === '["12","3","999+"]', badges.join(','));
await shot(page, 'deck-wide');
// 2. Paging with Alt+PageDown, then back.
await page.keyboard.press('Alt+PageDown');
await page.waitForTimeout(250);
titles = await columnTitles(page);
state = await harness(page);
boxes = state.sync.filter(item => item.role === 'column');
check('Alt+PageDown shows the last page, filled from the end', titles.length === 4 && titles[3] === '@playfishies' && titles[0] === 'Notifications', titles.join(', '));
check('the column that scrolled off is parked', boxes.filter(b => b.parked).map(b => b.id).join() === 'k0');
await shot(page, 'deck-page-2');
await page.keyboard.press('Alt+PageUp');
await page.waitForTimeout(200);
// 3. Focus keys: ] and [ and Alt+3; ignored while typing.
const focusedTitle = () => page.$eval('.bt-col.is-focused .bt-col-titles strong', el => el.textContent).catch(() => null);
await page.click('.bt-deck-bar .bt-deck-name');
await page.keyboard.press(']');
await page.waitForTimeout(150);
check('] focuses the next column', await focusedTitle() === 'Notifications', await focusedTitle());
await page.keyboard.press('Alt+3');
await page.waitForTimeout(150);
check('Alt+3 focuses the third column', await focusedTitle() === 'Mentions', await focusedTitle());
await page.keyboard.press('[');
await page.waitForTimeout(150);
check('[ focuses the previous column', await focusedTitle() === 'Notifications', await focusedTitle());
// 4. Filter popover.
await page.click('.bt-col:nth-of-type(2) .bt-col-btn[aria-label="Filters"]');
await page.waitForSelector('.bt-deck-pop');
check('webviews hidden while the popover is open', (await harness(page)).sync.filter(b => b.role === 'column').every(b => !b.visible));
await page.fill('.bt-deck-input', '/^gm\\b/');
await page.keyboard.press(']');
await page.keyboard.press('Backspace');
check('deck keys are ignored while typing', await page.$eval('.bt-deck-input', el => el.value) === '/^gm\\b/');
await page.keyboard.press('Enter');
await page.click('.bt-deck-check:nth-of-type(1)');
await page.waitForTimeout(150);
await shot(page, 'deck-filters');
state = await harness(page);
check('filter edits reach the column config', JSON.stringify(state.columns.k1?.filters) === JSON.stringify({ terms: ['giveaway', '/^gm\\b/'], hideReplies: true, hideReposts: false, mediaOnly: false }),
JSON.stringify(state.columns.k1?.filters));
await page.keyboard.press('Escape');
await page.waitForTimeout(150);
// 5. Column menu: move right, then remove.
await page.click('.bt-col:nth-of-type(1) .bt-col-btn[aria-label="Column menu"]');
await page.waitForSelector('.bt-deck-pop');
await shot(page, 'deck-menu');
await page.click('.bt-deck-pop button:has-text("Move right")');
await page.waitForTimeout(250);
titles = await columnTitles(page);
check('Move right reorders the columns', titles[0] === 'Notifications' && titles[1] === 'Home', titles.join(', '));
await page.click('.bt-col:nth-of-type(2) .bt-col-btn[aria-label="Column menu"]');
await page.click('.bt-deck-pop button:has-text("Remove column")');
await page.waitForTimeout(450);
state = await harness(page);
titles = await columnTitles(page);
check('Remove column drops it and its config', !titles.includes('Home') && !('k0' in state.columns) && !state.sync.some(b => b.id === 'k0'), titles.join(', '));
// 6. Add a column: Search with an operator, in the second account.
await page.click('.bt-deck-action:has-text("Column")');
await page.waitForSelector('.bt-addcol');
await page.click('.bt-addcol-type[data-kind="search"]');
await page.fill('#bt-addcol-field', 'wayBetter from:@playfishies');
await page.click('.bt-addcol-account:nth-child(2)');
await shot(page, 'deck-add-column');
await page.click('.bt-addcol-foot .bt-deck-primary');
await page.waitForTimeout(400);
state = await harness(page);
const added = Object.entries(state.columns).find(([, config]) => config.kind === 'search' && config.url.includes('wayBetter'));
check('Add column sends a search config (operators normalized)', !!added && added[1].url === 'https://x.com/search?q=wayBetter%20from%3Aplayfishies&f=live', added?.[1].url);
const addedBox = added && state.sync.find(b => b.id === added[0]);
check('the new column runs in the chosen account', addedBox?.profile === 'pwork1', addedBox?.profile);
await page.close();
// 7. Empty deck, add dialog, narrow window.
page = await open('deck=0&open=deck');
await page.waitForSelector('.bt-deck-empty');
await shot(page, 'deck-empty');
await page.close();
page = await open('deck=6&open=addcol&unread');
await page.waitForSelector('.bt-addcol');
await shot(page, 'deck-add-dialog');
await page.close();
page = await open('deck=6&open=deck&unread', { width: 1000, height: 700 });
await page.waitForSelector('.bt-col');
titles = await columnTitles(page);
check('1000 px: two columns per page', titles.length === 2, titles.join(', '));
await shot(page, 'deck-narrow');
await page.close();
await browser.close();
writeFileSync(join(out, 'deck-check.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 deck checks passed');
process.exit(failures.length || errors.length ? 1 : 0);