// Workspaces, plugin palette commands, developer mode and backup/sync in the shell and panel // preview harnesses (`/?shell`, `/?panel`, native commands mocked in src/shell/preview/settingsMocks.ts) // against a running `vite dev`: checks + screenshots into verification/platform/. // Usage: node scripts/platform-shots.mjs [baseUrl] [outDir] // Not a native test: files, crypto, the watcher and the tab bridge are Rust's (cargo tests). 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', 'platform'); mkdirSync(out, { recursive: true }); const WIDE = { width: 1440, height: 860 }; const NARROW = { width: 860, height: 640 }; const PANEL = { width: 1180, height: 900 }; const PANEL_NARROW = { width: 460, height: 900 }; const results = []; const errors = []; const check = (name, ok, detail = '') => { results.push({ name, ok: !!ok, detail }); console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); }; const browser = await chromium.launch(); async function open(path, viewport, name) { const page = await browser.newPage({ viewport, deviceScaleFactor: 1 }); page.on('pageerror', error => errors.push(`${name}: ${error.message}`)); page.on('console', message => { if (message.type() === 'error' && !/is not mocked/.test(message.text())) errors.push(`${name}: ${message.text()}`); }); // Record every native command the page calls. await page.addInitScript(() => { window.__calls = []; const wrap = () => { const internals = window.__TAURI_INTERNALS__; if (!internals || internals.__wrapped) return; const invoke = internals.invoke; internals.invoke = (cmd, args) => { window.__calls.push({ cmd, args }); return invoke(cmd, args); }; internals.__wrapped = true; }; const timer = setInterval(wrap, 5); setTimeout(() => clearInterval(timer), 8000); }); await page.goto(`${base}/${path}`); return page; } const shot = (page, name) => page.screenshot({ path: join(out, `${name}.png`) }); const calls = (page, cmd) => page.evaluate(command => window.__calls.filter(call => call.cmd === command).map(call => call.args), cmd); // 1. Workspaces: the titlebar menu, save, restore, default on start. { const page = await open('?shell&tabs=5&split&ws=3&wsactive=launch-day&open=workspaces', WIDE, 'workspace-menu'); await page.waitForSelector('.bt-ws-menu'); await page.waitForTimeout(400); const rows = await page.$$eval('.bt-ws-row .bt-ws-name', nodes => nodes.map(node => node.textContent)); check('workspace menu lists the saved workspaces', rows.length === 3 && rows[0].startsWith('Launch day'), rows.join(' | ')); check('the titlebar shows the active workspace', (await page.textContent('.bt-ws-button')).includes('Launch day')); await shot(page, 'workspace-menu'); // Restore "Night reading": its two tabs replace the layout. await page.locator('.bt-ws-row .bt-ws-main').nth(1).click(); await page.waitForTimeout(500); const layout = await page.evaluate(() => window.__btShellLayout()); const urls = layout.groups.flatMap(group => group.urls); check('restoring a workspace opens exactly its tabs', urls.join(',') === 'https://x.com/i/lists/1234,https://x.com/i/bookmarks', urls.join(',')); check('restored tabs get fresh ids', layout.groups.flatMap(group => group.tabs).every(id => id !== 'n1' && id !== 'n2')); check('the titlebar follows the restored workspace', (await page.textContent('.bt-ws-button')).includes('Night reading')); await shot(page, 'workspace-restored'); await page.close(); } { const page = await open('?shell&tabs=5&split&ws=1&open=wssave', WIDE, 'workspace-save'); await page.waitForSelector('.bt-ws-input'); await page.fill('.bt-ws-input', 'Research split'); await shot(page, 'workspace-save'); await page.press('.bt-ws-input', 'Enter'); await page.waitForTimeout(400); const saved = await calls(page, 'workspaces_set'); const store = saved.at(-1)?.store; const item = store?.items?.find(entry => entry.name === 'Research split'); const tabs = item ? Object.values(item.layout.panels) : []; const openTabs = (await page.evaluate(() => window.__btShellLayout())).groups.flatMap(group => group.tabs).length; check('saving stores the layout natively (workspaces_set)', !!item, `${store?.items?.length ?? 0} workspaces`); check('each saved tab keeps URL and account, no transient state', tabs.length === openTabs && tabs.every(tab => tab.params.url && tab.params.profile && !('loading' in tab.params) && !('favicon' in tab.params)), `${tabs.length} tabs`); await page.close(); } { const page = await open('?shell&tabs=4&ws=3&open=palette&q=switch', NARROW, 'workspace-palette'); await page.waitForSelector('.bt-palette'); await page.waitForTimeout(300); const labels = await page.$$eval('.bt-palette-item .bt-palette-label', nodes => nodes.map(node => node.textContent)); check('Ctrl+K switches workspaces', labels.slice(0, 3).every(label => label.startsWith('Switch to')), labels.slice(0, 3).join(' | ')); await shot(page, 'palette-workspaces-narrow'); await page.close(); } { // The workspace chosen for start opens instead of the last session. const page = await open('?shell&ws=3&wsdefault=two-accounts', WIDE, 'workspace-start'); await page.waitForSelector('.bt-ws-button'); await page.waitForTimeout(600); const layout = await page.evaluate(() => window.__btShellLayout()); const urls = layout.groups.flatMap(group => group.urls); check('the default workspace opens on start', urls.join(',') === 'https://x.com/home,https://x.com/home', urls.join(',')); check('its tabs keep their accounts', (await page.$$('.bt-tab-account')).length >= 1); await page.click('.bt-ws-button'); await page.waitForSelector('.bt-ws-menu'); const badge = await page.textContent('.bt-ws-row:has(.bt-ws-badge) .bt-ws-name'); check('the start workspace is marked "On start"', badge.includes('Two accounts'), badge); await shot(page, 'workspace-on-start'); await page.close(); } // 2. Plugin commands in the palette, under each plugin's name, run in the focused tab. { const page = await open('?shell&tabs=5&commands&ws=2&open=palette', WIDE, 'palette-commands'); await page.waitForSelector('.bt-palette'); await page.waitForTimeout(300); const groups = await page.$$eval('.bt-palette-group', nodes => nodes.map(node => node.textContent)); check('plugin commands are grouped under plugin names', ['Focus Mode', 'Hide Blue Checks', 'Thread Reader'].every(name => groups.includes(name)), groups.join(' | ')); await page.fill('.bt-palette-input', 'toggle focus'); await page.waitForTimeout(200); const first = await page.textContent('.bt-palette-item[aria-selected="true"] .bt-palette-label'); check('typing finds a plugin command', first === 'Toggle Focus Mode', first); await shot(page, 'palette-plugin-commands'); await page.keyboard.press('Enter'); await page.waitForTimeout(200); const runs = await calls(page, 'tab_run_command'); check('Enter asks the focused X tab to run it (tab_run_command)', runs.length === 1 && runs[0].command === 'bt-focus-mode.plugin.js:toggle' && /^tab\d$/.test(runs[0].id), JSON.stringify(runs)); await page.close(); } { const page = await open('?shell&tabs=5&commands&open=palette&q=thread', NARROW, 'palette-commands-narrow'); await page.waitForSelector('.bt-palette'); await page.waitForTimeout(300); await shot(page, 'palette-plugin-commands-narrow'); await page.close(); } // 3. Developer mode and sync chips in the titlebar. { const page = await open('?shell&tabs=6&dev=errors&ws=2&wsactive=launch-day', WIDE, 'dev-chip'); await page.waitForSelector('.bt-dev-chip'); await page.waitForTimeout(500); const chip = await page.textContent('.bt-dev-chip'); check('the dev chip shows load errors with file and line', chip.includes('2 errors') && chip.includes('my-plugin.plugin.js:42'), chip); const reserve = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--bt-tb-extra')); check('the tab strip reserves the extras width', parseInt(reserve, 10) > 100, reserve); await shot(page, 'titlebar-dev-errors'); await page.close(); } { const page = await open('?shell&tabs=4&sync=newer', WIDE, 'sync-chip'); await page.waitForSelector('.bt-sync-chip', { timeout: 5000 }).catch(() => {}); await page.waitForTimeout(500); const chip = await page.$('.bt-sync-chip'); check('a newer synced snapshot shows a chip', !!chip && (await chip.textContent()).includes('Studio iMac')); await shot(page, 'titlebar-sync-newer'); await page.close(); } // 4. Settings → Backup & sync and Developer. for (const [name, viewport] of [['panel-backup', PANEL], ['panel-backup-narrow', PANEL_NARROW]]) { const page = await open('?panel§ion=backup&sync=on', viewport, name); await page.waitForSelector('.btb-snapshot'); await page.waitForTimeout(300); await shot(page, name); await page.close(); } { const page = await open('?panel§ion=backup&sync=folder', PANEL, 'panel-sync-setup'); await page.waitForSelector('input[aria-label="Sync passphrase"]'); await page.fill('input[aria-label="Sync passphrase"]', 'correct horse battery'); await page.fill('input[aria-label="Repeat sync passphrase"]', 'correct horse battery'); await shot(page, 'panel-sync-setup'); await page.close(); } { const page = await open('?panel§ion=backup&sync=on', PANEL, 'panel-import'); await page.waitForSelector('text=Import…'); await page.click('text=Import…'); await page.waitForSelector('.btb-row'); await page.waitForTimeout(300); const rows = await page.$$eval('.btb-row', nodes => nodes.map(node => ({ title: node.querySelector('.btb-row-title').textContent, checked: node.querySelector('input').checked }))); check('import preview lists each section with a checkbox', rows.length >= 8, rows.map(row => `${row.title}${row.checked ? '✓' : ''}`).join(' | ')); check('unchanged sections are not ticked', rows.filter(row => row.title.includes('Same')).every(row => !row.checked)); await shot(page, 'panel-import-preview'); // Untick plugins, apply: only the ticked native sections reach backup_apply. await page.click('input[aria-label="Apply Plugins"]'); await page.click('text=Apply selected'); await page.waitForTimeout(300); const applied = await calls(page, 'backup_apply'); const keys = Object.keys(applied.at(-1)?.sections ?? {}); check('only ticked sections are applied (plugins unticked, decks applied by the shell)', keys.length > 0 && !keys.includes('plugins') && !keys.includes('decks'), keys.join(',')); await page.close(); } { const page = await open('?panel§ion=backup&sync=off', PANEL, 'panel-export'); await page.waitForSelector('text=Export settings…'); await page.click('.btb-check input'); await page.click('text=Export settings…'); await page.waitForTimeout(300); const exported = await calls(page, 'backup_export_file'); check('export passes "include drafts" and the shell\'s decks', exported[0]?.includeDrafts === true && !!exported[0]?.extra?.decks, JSON.stringify(Object.keys(exported[0]?.extra ?? {}))); await shot(page, 'panel-export'); await page.close(); } for (const [name, viewport] of [['panel-developer', PANEL], ['panel-developer-narrow', PANEL_NARROW]]) { const page = await open('?panel§ion=developer&dev=errors', viewport, name); await page.waitForSelector('.btd-error'); const where = await page.$$eval('.btd-where', nodes => nodes.map(node => node.textContent)); check(`${name}: errors show file:line:column`, where.includes('my-plugin.plugin.js:42:17') && where.includes('my-theme.json:12:5'), where.join(' | ')); await shot(page, name); await page.close(); } await browser.close(); const failed = results.filter(result => !result.ok); console.log(`\n${results.length - failed.length}/${results.length} checks passed`); console.log(errors.length ? `page errors:\n${errors.join('\n')}` : 'no page errors'); process.exit(failed.length || errors.length ? 1 : 0);