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

107 lines
7.6 KiB
JavaScript

// Live check of install links, dropped files, the install screen, plugin permissions and the Store
// in the running native app (release build started with
// WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=<port>). Plain CDP on the shell
// webview. X pages are not touched. Whatever this installs is removed again at the end.
//
// node scripts/store-live-check.mjs <port> <outDir> <exe>
import { execFileSync, spawn } from 'node:child_process';
import { existsSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const [port = '9333', out = '../../verification/store', exe] = process.argv.slice(2);
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'));
if (!shellTarget) throw new Error('shell webview not found');
const ws = new WebSocket(shellTarget.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(result.result.exceptionDetails.exception?.description ?? result.result.exceptionDetails.text);
return result.result?.result?.value;
};
const invoke = (cmd, args = {}) => evaluate(`window.__TAURI_INTERNALS__.invoke(${JSON.stringify(cmd)}, ${JSON.stringify(args)})`);
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
const shot = async name => {
const { result } = await send('Page.captureScreenshot', { format: 'png' });
writeFileSync(join(out, `${name}.png`), Buffer.from(result.data, 'base64'));
};
const failures = [];
const check = (label, ok, detail = '') => { console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${detail ? ` (${detail})` : ''}`); if (!ok) failures.push(label); };
const text = sel => evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`);
const waitFor = async (sel, ms = 15000) => { for (let t = 0; t < ms; t += 200) { if (await evaluate(`!!document.querySelector(${JSON.stringify(sel)})`)) return true; await wait(200); } return false; };
const click = sel => evaluate(`(() => { const el = document.querySelector(${JSON.stringify(sel)}); el?.click(); return !!el; })()`);
const before = await invoke('plugins_list');
const pluginsDir = process.env.APPDATA ? join(process.env.APPDATA, 'app.bettertwttr.desktop', 'plugins') : null;
// 1. A waybetter:// link through the OS path: a second launch with the link as its argument is
// forwarded by single-instance to the deep-link handler of the running app.
if (exe) {
spawn(exe, ['waybetter://install?kind=theme&url=https%3A%2F%2Fwaybetter.fyi%2Fstore%2Fthemes%2Fnord.json'], { detached: true, stdio: 'ignore' }).unref();
const shown = await waitFor('.wbi');
check('deep link: second launch opens the install screen in the running app', shown);
await waitFor('.wbi.is-ready, .wbi.is-error', 20000);
const state = await evaluate(`document.querySelector('.wbi')?.className`);
const body = await text('.wbi');
// waybetter.fyi/store is not deployed yet: the download fails, and nothing is installed.
check('deep link: downloaded and shown, or refused with the reason', /is-ready|is-error/.test(state ?? ''), (body ?? '').split('\n').slice(0, 3).join(' / '));
await shot('live-deep-link');
await evaluate(`document.querySelector('.wbi-close, .wbi-state-actions button')?.click()`);
await wait(300);
// A link to a host that is not allowed never reaches a download.
spawn(exe, ['waybetter://install?kind=plugin&url=https%3A%2F%2Fevil.example%2Fx.plugin.js'], { detached: true, stdio: 'ignore' }).unref();
await waitFor('.wbi.is-error');
check('deep link: disallowed host refused', ((await text('.wbi')) ?? '').includes('only https links on waybetter.fyi'));
await shot('live-deep-link-refused');
await click('.wbi-state-actions button');
await wait(300);
}
// 2. A dropped plugin file: the real install screen, native validation, hash, permissions.
const source = '/**\n * @name Live Check Badge\n * @description Temporary plugin from scripts/store-live-check.mjs; removed at the end.\n * @version 1.0.0\n * @author wayBetter\n * @permissions ui, network:none\n */\nmodule.exports = class LiveCheckBadge {\n start() { this.scope = window.wayBetter.api.scope(); }\n stop() { this.scope.dispose(); }\n};\n';
await evaluate(`(() => {
const dt = new DataTransfer();
dt.items.add(new File([${JSON.stringify(source)}], 'live-check-badge.plugin.js', { type: 'text/javascript' }));
const target = document.querySelector('.bt-titlebar') ?? document.body;
for (const type of ['dragenter', 'dragover', 'drop']) target.dispatchEvent(new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: dt }));
})()`);
check('drop: install screen', await waitFor('.wbi.is-ready'));
const screen = (await text('.wbi')) ?? '';
check('drop: validated natively, hash and permissions shown', screen.includes('Live Check Badge') && /[0-9a-f]{64}/.test(screen) && screen.includes('Dialogs') && screen.includes('No network'));
check('drop: Install disabled at first', await evaluate(`document.querySelector('.wbi-install')?.disabled === true`));
await wait(1200);
await shot('live-install-dropped-plugin');
// Leave it off: uncheck "Turn it on now", then install.
await evaluate(`(() => { const box = document.querySelector('.wbi-check input'); if (box?.checked) box.click(); })()`);
await click('.wbi-install');
check('drop: installed', await waitFor('.wbi.is-done'));
const file = pluginsDir ? join(pluginsDir, 'live-check-badge.plugin.js') : null;
check('drop: file written to the plugins folder', file ? existsSync(file) : true, file ?? '');
const after = await invoke('plugins_list');
const installed = after.find(p => p.file === 'live-check-badge.plugin.js');
check('drop: listed with its permissions, off', installed && !installed.enabled && JSON.stringify(installed.permissions) === '["ui","network:none"]');
await click('.wbi-state-actions .bt-deck-secondary'); // Open Plugins
await wait(900);
await shot('live-plugins-permissions');
// 3. The Store section (the catalog is not deployed yet: the honest offline state).
await evaluate(`(() => { const b = [...document.querySelectorAll('.btp-nav button')].find(x => x.textContent.trim() === 'Store'); b?.click(); return !!b; })()`);
await wait(2500);
const store = (await text('.btp-page')) ?? '';
check('store section renders', store.includes('Store'), store.split('\n').slice(0, 4).join(' / '));
await shot('live-store');
// 4. Clean up: remove the test plugin; the owner's plugins are as before.
await invoke('plugin_remove', { file: 'live-check-badge.plugin.js' });
const final = await invoke('plugins_list');
check('cleanup: test plugin removed', !final.some(p => p.file === 'live-check-badge.plugin.js') && (file ? !existsSync(file) : true));
check('cleanup: owner plugins unchanged', JSON.stringify(final.map(p => [p.file, p.enabled])) === JSON.stringify(before.map(p => [p.file, p.enabled])));
await evaluate(`(() => { const b = [...document.querySelectorAll('.btp-nav button')].find(x => x.textContent.trim() === 'Plugins'); b?.click(); })()`);
ws.close();
console.log(failures.length ? `\n${failures.length} failure(s): ${failures.join('; ')}` : '\nall checks passed');
process.exit(failures.length ? 1 : 0);