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