107 lines
7.6 KiB
JavaScript
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);
|