161 lines
9.3 KiB
JavaScript
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);
|