202 lines
15 KiB
JavaScript
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());
|
|
}
|