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

202 lines
15 KiB
JavaScript

// Live check of plugin palette commands, workspaces, backup and encrypted folder sync, and the
// developer-mode watcher in the running native app (release build started with
// WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=<port>). Plain CDP. X pages are only
// read (the Focus Mode command hides counts locally); nothing is liked, posted or followed. The
// owner's state (plugin on/off, workspaces, sync settings, developer mode) is saved first and
// restored at the end; the test plugin and the temporary sync folder are deleted.
//
// node scripts/platform-live-check.mjs <port> <outDir>
// SKIP=commands,workspaces leaves those parts out (the workspace restore reloads every tab)
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const [port = '9333', out = '../../verification/platform'] = process.argv.slice(2);
const skip = new Set((process.env.SKIP ?? '').split(',').filter(Boolean));
mkdirSync(out, { recursive: true });
const appData = join(process.env.APPDATA ?? '', 'app.bettertwttr.desktop');
const targets = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
const shellTarget = targets.find(t => t.type === 'page' && t.url.startsWith('http://tauri.localhost'));
const xTargets = targets.filter(t => t.type === 'page' && t.url.startsWith('https://x.com/'));
if (!shellTarget || !xTargets.length) throw new Error('shell or X tab not found');
function connect(target) {
const ws = new WebSocket(target.webSocketDebuggerUrl);
let id = 0;
const pending = new Map();
ws.onmessage = event => { const msg = JSON.parse(event.data); pending.get(msg.id)?.(msg); pending.delete(msg.id); };
const ready = new Promise(resolve => { ws.onopen = resolve; });
const send = async (method, params = {}) => { await ready; return new Promise(resolve => { const n = ++id; pending.set(n, resolve); ws.send(JSON.stringify({ id: n, method, params })); }); };
const evaluate = async expression => {
const result = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (result.result?.exceptionDetails) throw new Error(`${target.url}: ${result.result.exceptionDetails.exception?.description ?? result.result.exceptionDetails.text}`);
return result.result?.result?.value;
};
return { send, evaluate, close: () => ws.close(), url: target.url };
}
const shell = connect(shellTarget);
const xs = xTargets.map(connect);
let x = xs[0];
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
const results = [];
const check = (name, ok, detail = '') => { results.push({ name, ok: !!ok }); console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); };
const invoke = (cmd, args = {}) => shell.evaluate(`window.__TAURI_INTERNALS__.invoke(${JSON.stringify(cmd)}, ${JSON.stringify(args)})`);
const invokeError = (cmd, args = {}) => shell.evaluate(`window.__TAURI_INTERNALS__.invoke(${JSON.stringify(cmd)}, ${JSON.stringify(args)}).then(() => 'no error', e => String(e))`);
const shot = async name => {
const result = await shell.send('Page.captureScreenshot', { format: 'png' });
if (result.result?.data) writeFileSync(join(out, `${name}.png`), Buffer.from(result.result.data, 'base64'));
};
const xLabel = await x.evaluate(`window.__TAURI_INTERNALS__?.metadata?.currentWebview?.label ?? ''`);
const tabId = xLabel.replace(/^tab-/, '');
const FOCUS = 'bt-focus-mode.plugin.js';
const TEST_PLUGIN = 'wb-live-check.plugin.js';
const plugins0 = await invoke('plugins_list');
const focusWasOn = plugins0.find(plugin => plugin.file === FOCUS)?.enabled ?? false;
const workspaces0 = await invoke('workspaces_get');
const dev0 = await invoke('dev_get');
const syncFile = join(appData, 'sync.json');
const syncBackup = existsSync(syncFile) ? readFileSync(syncFile) : null;
const keyFile = join(appData, 'sync-key.json');
const keyBackup = existsSync(keyFile) ? readFileSync(keyFile) : null;
const syncDir = mkdtempSync(join(tmpdir(), 'wb-sync-'));
console.log(`X tab ${tabId} (${x.url}); focus mode was ${focusWasOn ? 'on' : 'off'}; ${workspaces0.items.length} workspaces; dev ${dev0.enabled ? 'on' : 'off'}`);
try {
// 1. Plugin commands: Focus Mode registers two; the tab reports them; the shell runs one there.
if (!skip.has('commands')) {
if (!focusWasOn) await invoke('plugin_set_enabled', { file: FOCUS, enabled: true });
await wait(1500);
const listed = await x.evaluate(`window.wayBetter.api.commands.list().map(c => c.id)`);
check('Focus Mode registered its commands in the X tab', listed.includes(`${FOCUS}:toggle`) && listed.includes(`${FOCUS}:settings`), listed.join(', '));
const cssBefore = await x.evaluate(`!!document.querySelector('style[data-better-twttr-plugin="focus-mode"]')`);
await invoke('tab_run_command', { id: tabId, command: `${FOCUS}:toggle` });
await wait(700);
const cssAfter = await x.evaluate(`!!document.querySelector('style[data-better-twttr-plugin="focus-mode"]')`);
check('tab_run_command ran the toggle in that tab', cssBefore !== cssAfter, `focus CSS ${cssBefore} → ${cssAfter}`);
await invoke('tab_run_command', { id: tabId, command: `${FOCUS}:toggle` });
await wait(500);
check('an unreported command is refused natively', /no such command/.test(await invokeError('tab_run_command', { id: tabId, command: `${FOCUS}:nope` })));
check('a malformed id is refused natively', /no such command|VALIDATION/.test(await invokeError('tab_run_command', { id: tabId, command: '_:a");alert(1);("' })));
// The palette lists them under "Focus Mode" (focus the X tab's panel first).
await shell.evaluate(`(() => { const tab = [...document.querySelectorAll('.dv-tab')].find(t => t.querySelector('.bt-tab')); return true; })()`);
await shell.send('Input.dispatchKeyEvent', { type: 'rawKeyDown', key: 'k', code: 'KeyK', modifiers: 2, windowsVirtualKeyCode: 75 });
await shell.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'k', code: 'KeyK', modifiers: 2, windowsVirtualKeyCode: 75 });
await wait(600);
await shell.evaluate(`(() => { const input = document.querySelector('.bt-palette-input'); const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; set.call(input, 'focus mode'); input.dispatchEvent(new Event('input', { bubbles: true })); })()`);
await wait(500);
const groups = await shell.evaluate(`[...document.querySelectorAll('.bt-palette-group')].map(g => g.textContent)`);
check('Ctrl+K lists the commands under the plugin name', groups.includes('Focus Mode'), groups.join(' | '));
await shot('app-palette-plugin-commands');
await shell.send('Input.dispatchKeyEvent', { type: 'rawKeyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
await wait(300);
// Stopping the plugin removes its commands in the page and in the shell's list.
await invoke('plugin_set_enabled', { file: FOCUS, enabled: false });
await wait(800);
const afterStop = await x.evaluate(`window.wayBetter.api.commands.list().map(c => c.id)`);
check('turning the plugin off removes its commands', !afterStop.some(id => id.startsWith(FOCUS)), afterStop.join(', ') || 'none');
}
// 2. Workspaces: save the live layout through the titlebar menu, restore it, delete it.
if (!skip.has('workspaces')) {
await shell.evaluate(`document.querySelector('.bt-ws-button').click()`);
await wait(400);
await shell.evaluate(`[...document.querySelectorAll('.bt-ws-save')].at(0)?.click()`);
await wait(300);
await shell.evaluate(`(() => { const input = document.querySelector('.bt-ws-input'); const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; set.call(input, 'Live check'); input.dispatchEvent(new Event('input', { bubbles: true })); })()`);
await wait(200);
await shot('app-workspace-save');
await shell.evaluate(`document.querySelector('.bt-ws-form').requestSubmit()`);
await wait(800);
const stored = await invoke('workspaces_get');
const saved = stored.items.find(item => item.name === 'Live check');
const savedUrls = saved ? Object.values(saved.layout.panels).map(panel => panel.params.url).filter(Boolean) : [];
check('the layout was saved natively (workspaces.json)', !!saved, `${savedUrls.length} web tabs`);
await shell.evaluate(`document.querySelector('.bt-ws-button').click()`);
await wait(400);
await shot('app-workspace-menu');
await shell.evaluate(`[...document.querySelectorAll('.bt-ws-main')].find(b => b.textContent.startsWith('Live check')).click()`);
await wait(3000);
const titles = await shell.evaluate(`document.querySelector('.bt-ws-button').textContent`);
check('restoring it keeps the titlebar on that workspace', titles.includes('Live check'), titles);
const refreshed = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
const xNow = refreshed.filter(t => t.type === 'page' && t.url.startsWith('https://')).map(t => t.url);
check('restored tabs load their pages in new webviews', savedUrls.every(url => xNow.some(now => now.split('?')[0] === url.split('?')[0])), `${xNow.length} pages`);
await invoke('workspaces_set', { store: workspaces0 });
// The restored tabs are new webviews: talk to the new X tab from here on.
await wait(2000);
const fresh = (await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()).find(t => t.type === 'page' && t.url.startsWith('https://x.com/'));
if (fresh) { x = connect(fresh); xs.push(x); }
await x.evaluate('document.readyState');
}
// 3. Backup: every section, no secrets; applying the same alerts is a no-op.
const backup = await invoke('backup_collect', { includeDrafts: false });
const text = JSON.stringify(backup);
check('backup has every native section', ['themes', 'plugins', 'customCss', 'features', 'settings', 'alerts', 'accounts', 'workspaces'].every(key => key in backup), Object.keys(backup).join(','));
check('backup carries no cookie, token, session or key fields', !/"(cookies?|auth_?token|ct0|csrf|password|session(Id)?|privateKey|signingKey|key)"\s*:/i.test(text) && !text.includes('EBWebView'));
check('accounts are names and colors only', backup.accounts.profiles.every(profile => Object.keys(profile).sort().join() === 'color,id,name'));
check('drafts only when asked', !('drafts' in backup) && 'drafts' in await invoke('backup_collect', { includeDrafts: true }));
const applied = await invoke('backup_apply', { sections: { alerts: backup.alerts } });
check('backup_apply applies a section through its setter', applied.applied.includes('alerts'), JSON.stringify(applied));
// 4. Encrypted folder sync, through a temporary folder (the dialog is replaced by sync.json).
const local = JSON.parse(syncBackup?.toString() || '{}');
writeFileSync(syncFile, JSON.stringify({ ...local, folder: syncDir, device: local.device || 'a1b2c3d4e5f60718', deviceName: local.deviceName || 'Live check', autoPush: false, lastPulled: 0, lastPushedHash: '', lastPushedAt: 0 }));
const status0 = await invoke('sync_status');
check('a new sync folder is not set up yet', status0.folder === syncDir && !status0.initialized && !status0.unlocked);
const started = Date.now();
await invoke('sync_unlock', { passphrase: 'live check passphrase', remember: false });
check('setting up derives the key (Argon2id, 64 MiB)', true, `${Date.now() - started} ms`);
const pushed = await invoke('sync_push', { extra: null, force: true });
const snapshots = readdirSync(join(syncDir, 'wayBetter Sync', 'snapshots'));
const sealed = readFileSync(join(syncDir, 'wayBetter Sync', 'snapshots', snapshots[0]), 'utf8');
check('a snapshot was written, encrypted', pushed.pushed && snapshots.length === 1 && !sealed.includes('homeUrl') && !sealed.includes('x.com'), snapshots.join(','));
const unchanged = await invoke('sync_push', { extra: null, force: false }).catch(error => ({ error: String(error) }));
check('an unchanged push writes nothing (auto push is off here)', !unchanged.pushed);
await invoke('sync_lock');
check('the wrong passphrase is refused', /wrong passphrase/.test(await invokeError('sync_unlock', { passphrase: 'not the passphrase', remember: false })));
const reopened = await invoke('sync_unlock', { passphrase: 'live check passphrase', remember: false });
check('the right passphrase unlocks again', reopened.unlocked && reopened.snapshots.length === 1);
const opened = await invoke('sync_open', { name: snapshots[0] });
check('the snapshot decrypts to the settings (never drafts)', 'settings' in opened && !('drafts' in opened));
await invoke('sync_lock');
// 5. Developer mode: a test plugin reloads on save; a syntax error is caught with its line.
const pluginsDir = dev0.pluginsFolder;
const source = version => `/**\n * @name Live check\n * @version ${version}\n * @permissions commands, network:none\n */\nmodule.exports = class {\n start() { window.__wbLive = '${version}'; this.scope = wayBetter.api.scope(); this.scope.commands.register({ id: 'hello', title: 'Live check ${version}', run() {} }); }\n stop() { window.__wbLive = 'stopped ${version}'; this.scope.dispose(); }\n};\n`;
writeFileSync(join(pluginsDir, TEST_PLUGIN), source('1'));
await wait(400);
await invoke('plugin_set_enabled', { file: TEST_PLUGIN, enabled: true });
await invoke('dev_set', { enabled: true });
await wait(1200);
check('the test plugin runs', await x.evaluate('window.__wbLive') === '1');
writeFileSync(join(pluginsDir, TEST_PLUGIN), source('2'));
await wait(2500);
const v2 = await x.evaluate('[window.__wbLive, window.wayBetter.api.commands.list().filter(c => c.id.startsWith("wb-live")).map(c => c.title).join()]');
check('saving reloads it in the X tab (stop, then the new version)', v2[0] === '2' && v2[1] === 'Live check 2', JSON.stringify(v2));
writeFileSync(join(pluginsDir, TEST_PLUGIN), source('3').replace("start() {", "start() { const x = ;"));
await wait(2500);
const dev = await invoke('dev_get');
const error = dev.errors.find(entry => entry.file === TEST_PLUGIN);
check('a syntax error is reported with file, line and column; the old version keeps running', !!error && error.line === 7 && await x.evaluate('window.__wbLive') === '2', error ? `${error.file}:${error.line}:${error.column} ${error.message}` : 'no error');
await shot('app-dev-error-chip');
writeFileSync(join(pluginsDir, TEST_PLUGIN), source('4').replace("start() {", "start() { null.boom;"));
await wait(2500);
const toast = await x.evaluate(`(() => { const host = document.getElementById('bt-plugin-toast'); return host?.shadowRoot?.textContent ?? ''; })()`);
check('a start error shows as a toast in the X tab with file and line', /wb-live-check\.plugin\.js:7/.test(toast), toast.slice(0, 160));
} finally {
await invoke('plugin_set_enabled', { file: TEST_PLUGIN, enabled: false }).catch(() => {});
try { rmSync(join(dev0.pluginsFolder, TEST_PLUGIN)); } catch { /* not created */ }
await invoke('dev_set', { enabled: dev0.enabled }).catch(() => {});
await invoke('plugin_set_enabled', { file: FOCUS, enabled: focusWasOn }).catch(() => {});
await invoke('workspaces_set', { store: workspaces0 }).catch(() => {});
await invoke('sync_lock').catch(() => {});
if (syncBackup) writeFileSync(syncFile, syncBackup); else rmSync(syncFile, { force: true });
if (keyBackup) writeFileSync(keyFile, keyBackup); else rmSync(keyFile, { force: true });
rmSync(syncDir, { recursive: true, force: true });
const failed = results.filter(result => !result.ok).length;
console.log(`\n${results.length - failed}/${results.length} live checks passed; owner state restored`);
shell.close(); xs.forEach(tab => tab.close());
}