// Account switcher screenshots and flow checks on the shell preview harness (`/?shell`, needs `vite dev`). // Usage: node scripts/accounts-ux-shots.mjs [baseUrl] [outDir] [prefix] // prefix "before" only takes screenshots; any other prefix also checks the click flow. import { chromium } from 'playwright'; import { mkdirSync } from 'node:fs'; import { join } from 'node:path'; const base = process.argv[2] ?? 'http://127.0.0.1:1420'; const out = process.argv[3] ?? join(import.meta.dirname, '..', '..', '..', 'verification', 'accounts-ux'); const prefix = process.argv[4] ?? 'after'; mkdirSync(out, { recursive: true }); const WIDE = { width: 1440, height: 860 }; 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(); async function open(query, viewport = WIDE) { const page = await browser.newPage({ viewport }); page.on('pageerror', error => failures.push(`page error: ${error.message}`)); await page.goto(`${base}/?shell&${query}`); await page.waitForSelector('.bt-profile-switcher'); await page.waitForTimeout(400); return page; } const layout = page => page.evaluate(() => window.__btShellLayout()); const shot = (page, name) => page.screenshot({ path: join(out, `${prefix}-${name}.png`) }); // Menu open, 4 accounts (two signed in, one signed out, one never signed in), split view. let page = await open('tabs=5&profiles=4&split&open=accounts'); await page.waitForTimeout(300); await shot(page, 'menu'); await page.close(); page = await open('tabs=5&profiles=4&split'); await shot(page, 'panes'); if (prefix !== 'before') { // The focused pane's account is the one the titlebar shows and highlights. const focusedTab = await page.evaluate(() => window.__btShellLayout().active); const title = await page.getAttribute('.bt-profile-switcher', 'aria-label'); check('titlebar names the focused pane’s account', /CRT kafa|Sample|Shitposts|New account/.test(title ?? ''), `${focusedTab}: ${title}`); // Click an account that has no visible pane → a new pane on the right with its home feed. await page.click('.bt-profile-switcher'); await page.waitForSelector('.bt-acct-menu'); const before = await layout(page); await page.click('.bt-acct-row[data-profile="pnews01"] .bt-acct-main'); await page.waitForTimeout(300); let after = await layout(page); check('never-signed-in account opens X sign-in in a new pane', after.groups.length === before.groups.length + 1 && after.groups.some(group => group.urls.includes('https://x.com/i/flow/login')), JSON.stringify(after.groups.map(g => g.urls))); check('menu closes after a pick', !(await page.$('.bt-acct-menu'))); // Click an account whose pane is visible → that pane is focused, nothing new opens. const visibleDefault = await page.evaluate(() => { const api = window.__btShellLayout(); return api.groups.map(group => group.active); }); await page.click('.bt-profile-switcher'); await page.click('.bt-acct-row[data-profile="default"] .bt-acct-main'); await page.waitForTimeout(250); const refocus = await layout(page); check('visible account pane is focused instead of duplicated', refocus.groups.length === after.groups.length && refocus.groups.flatMap(g => g.tabs).length === after.groups.flatMap(g => g.tabs).length, `active=${refocus.active} visible=${visibleDefault}`); // The menu starts on the focused pane's account, not simply the first row. await page.click('.bt-profile-switcher'); await page.click('.bt-acct-row[data-profile="pwork1"] .bt-acct-main', { modifiers: ['Shift'] }); await page.waitForTimeout(250); await page.click('.bt-profile-switcher'); await page.waitForTimeout(150); const startRow = await page.evaluate(() => document.activeElement?.closest('.bt-acct-row')?.getAttribute('data-profile')); const focusedRow = await page.evaluate(() => document.querySelector('.bt-acct-row.is-focused')?.getAttribute('data-profile')); check('menu opens on the focused pane’s account (not the first row)', startRow === 'pwork1' && focusedRow === 'pwork1', `${startRow} / ${focusedRow}`); await page.keyboard.press('Escape'); // Keyboard: open with Enter, arrows move, Enter picks, Escape closes. await page.focus('.bt-profile-switcher'); await page.keyboard.press('Enter'); await page.waitForSelector('.bt-acct-menu'); const focused0 = await page.evaluate(() => document.activeElement?.closest('.bt-acct-row')?.getAttribute('data-profile')); await page.keyboard.press('ArrowDown'); const focused1 = await page.evaluate(() => document.activeElement?.closest('.bt-acct-row')?.getAttribute('data-profile')); check('menu opens with focus on an account and arrows move it', !!focused0 && !!focused1 && focused0 !== focused1, `${focused0} → ${focused1}`); await page.keyboard.press('ArrowRight'); await page.waitForTimeout(100); check('ArrowRight opens the row’s actions', !!(await page.$('.bt-acct-sub'))); await shot(page, 'row-actions'); await page.keyboard.press('Escape'); check('Escape closes the row’s actions first', !(await page.$('.bt-acct-sub')) && !!(await page.$('.bt-acct-menu'))); await page.keyboard.press('Escape'); check('Escape closes the menu', !(await page.$('.bt-acct-menu'))); // Shift+click forces a new tab (in the focused group) even when a pane is visible. const beforeShift = await layout(page); await page.click('.bt-profile-switcher'); await page.click('.bt-acct-row[data-profile="default"] .bt-acct-main', { modifiers: ['Shift'] }); await page.waitForTimeout(250); const afterShift = await layout(page); check('Shift+click opens a new tab', afterShift.groups.flatMap(g => g.tabs).length === beforeShift.groups.flatMap(g => g.tabs).length + 1 && afterShift.groups.length === beforeShift.groups.length); // Use for new tabs from the row menu. await page.click('.bt-profile-switcher'); await page.click('.bt-acct-row[data-profile="pwork1"] .bt-acct-more'); await page.click('.bt-acct-sub [data-action="default"]'); await page.waitForTimeout(150); const active = await page.evaluate(async () => (await window.__TAURI_INTERNALS__.invoke('profiles_get')).active); check('"Use for new tabs" sets the default account', active === 'pwork1', active); check('the menu stays open to show the new default', !!(await page.$('.bt-acct-row[data-profile="pwork1"] .bt-acct-badge'))); await page.waitForTimeout(250); await shot(page, 'menu-after-flow'); // The harness refuses like native code while a profile still has a synced tab. const refused = await page.evaluate(async () => { const current = await window.__TAURI_INTERNALS__.invoke('profiles_get'); try { await window.__TAURI_INTERNALS__.invoke('profiles_set', { settings: { ...current, profiles: current.profiles.filter(p => p.id !== 'pmemes1') } }); return false; } catch { return true; } }); check('removing an account with open tabs directly is refused (harness mirrors native)', refused); // Remove asks first. await page.click('.bt-acct-row[data-profile="pmemes1"] .bt-acct-more'); await page.click('.bt-acct-sub [data-action="remove"]'); check('Remove asks for confirmation', !!(await page.$('.bt-acct-confirm'))); await shot(page, 'remove-confirm'); await page.keyboard.press('Escape'); check('Cancel keeps the account', !(await page.$('.bt-acct-confirm')) && !!(await page.$('.bt-acct-row[data-profile="pmemes1"]'))); await page.click('.bt-acct-row[data-profile="pmemes1"] .bt-acct-more'); await page.click('.bt-acct-sub [data-action="remove"]'); await page.click('.bt-acct-confirm .bt-acct-remove'); await page.waitForTimeout(250); const left = await page.evaluate(async () => (await window.__TAURI_INTERNALS__.invoke('profiles_get')).profiles.map(p => p.id)); const openProfiles = await page.evaluate(() => window.__btShellLayout().groups.flatMap(g => g.tabs)); check('Remove closes the account’s tabs, then removes it', !left.includes('pmemes1') && !openProfiles.includes('tab2'), `${left} / ${openProfiles}`); await page.keyboard.press('Escape'); await page.waitForTimeout(150); await shot(page, 'panes-after-flow'); } await page.close(); // Single-account titlebar. page = await open('tabs=4&profiles=1&open=accounts'); await page.waitForTimeout(300); await shot(page, 'single-account'); await page.close(); await browser.close(); if (failures.length) { console.error(`\n${failures.length} failure(s):\n${failures.join('\n')}`); process.exit(1); } console.log(`\nScreenshots in ${out}`);