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

216 lines
12 KiB
JavaScript

// 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&section=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&section=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&section=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&section=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&section=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);