waybetter-for-x/apps/desktop/scripts/theme-live-check.mjs
2026-10-02 02:54:04 +07:00

111 lines
7.3 KiB
JavaScript

// Live check of the Theme Builder, Google Fonts and pane themes in the running native app (release
// build started with WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=<port>). Plain
// CDP (Playwright's connectOverCDP trips over X's shared workers). X pages are only read, never
// clicked. The owner's theme state is saved first and restored at the end.
//
// node scripts/theme-live-check.mjs <port> <outDir> [capture.ps1]
import { execFileSync } from 'node:child_process';
const [port = '9333', out = '../../verification/theme-builder', capture] = 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'));
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);
const log = [];
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
const snap = name => { if (capture) log.push(execFileSync('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', capture, '-Out', `${out}/${name}.png`]).toString().trim()); };
const invoke = (cmd, args = {}) => shell.evaluate(`window.__TAURI_INTERNALS__.invoke(${JSON.stringify(cmd)}, ${JSON.stringify(args)})`);
const key = async (keyName, code, modifiers) => {
for (const type of ['rawKeyDown', 'keyUp']) await shell.send('Input.dispatchKeyEvent', { type, key: keyName, code, modifiers, windowsVirtualKeyCode: keyName.toUpperCase().charCodeAt(0) });
};
/** Runs DOM steps in the shell: `$(selector)`, `click(el)`, `type(el, text)` (React-compatible). */
const dom = body => shell.evaluate(`(async () => {
const $ = (s, root = document) => root.querySelector(s);
const $$ = (s, root = document) => [...root.querySelectorAll(s)];
const wait = ms => new Promise(r => setTimeout(r, ms));
const click = el => { if (!el) throw new Error('missing element'); el.click(); };
const type = (el, text) => { const set = Object.getOwnPropertyDescriptor(el.constructor.prototype, 'value').set; set.call(el, text); el.dispatchEvent(new Event('input', { bubbles: true })); };
const byText = (s, text) => $$(s).find(el => el.textContent.trim().startsWith(text));
${body}
})()`);
const xState = x => x.evaluate(`(() => ({
url: location.pathname,
body: getComputedStyle(document.body).backgroundColor,
theme: window.wayBetter?.theme.current().preset,
heading: window.wayBetter?.api.theme.current().fonts?.heading.stack.slice(0, 36),
googleFaces: [...document.fonts].filter(f => f.wbGoogle).map(f => f.family + ':' + f.status).slice(0, 4),
nameFont: getComputedStyle(document.querySelector('[data-testid="User-Name"] > div:first-child span') ?? document.body).fontFamily.slice(0, 36),
}))()`);
const original = { theme: await invoke('theme_get'), scopes: await invoke('theme_scopes_get'), fonts: await invoke('gfonts_installed') };
log.push(`original: preset ${original.theme.preset}, scopes ${JSON.stringify(original.scopes)}, fonts ${original.fonts.map(f => f.family).join(',') || 'none'}`);
try {
// 1. A real native Google Fonts download into app data.
const font = await invoke('gfonts_install', { family: 'Space Grotesk', weights: { kind: 'range', min: 300, max: 700 } });
log.push(`installed ${font.family}: ${font.faces} faces, ${font.bytes} bytes`);
const faces = await invoke('gfonts_faces', { family: 'Space Grotesk' });
log.push(`faces: ${faces.faces.map(face => `${face.weight.join('-')}:${face.data.length}`).join(' ')}`);
const bad = await invoke('gfonts_install', { family: '../evil', weights: { kind: 'range', min: 100, max: 900 } }).catch(error => String(error));
log.push(`invalid family: ${bad}`);
// 2. The Theme Builder in the panel; a Nord remix with a pink accent and Space Grotesk names, live on X.
await key('W', 'KeyW', 2 | 8);
await wait(1500);
await shell.evaluate(`window.dispatchEvent(new CustomEvent('bt-panel-section', { detail: 'builder' }))`);
await wait(1500);
await dom(`
click($('button[aria-label^="Start from"]')); await wait(200);
click(byText('.bt-select-list li', 'Nord')); await wait(300);
const accent = $('input[aria-label="Accent color hex"]'); type(accent, '#f472b6'); accent.dispatchEvent(new FocusEvent('blur')); accent.blur();
await wait(300);
click($('button[aria-label^="Names and headings font"]')); await wait(400);
type($('input[aria-label="Search Google Fonts"]'), 'Space Grotesk'); await wait(500);
click($$('.wb-fb-list li[role=option]').find(li => li.textContent.includes('Space Grotesk'))); await wait(1500);
`);
await wait(1500);
snap('app-01-font-browser');
await dom(`click($$('.wb-fb-actions button').find(b => /names and headings/i.test(b.textContent))); await wait(3000);`);
await wait(2500);
for (const x of xs) log.push(`builder live ${JSON.stringify(await xState(x))}`);
snap('app-02-builder-live');
// 3. Leaving the builder ends the preview: every tab gets its own theme back.
await shell.evaluate(`window.dispatchEvent(new CustomEvent('bt-panel-section', { detail: 'themes' }))`);
await wait(2500);
for (const x of xs) log.push(`builder closed ${JSON.stringify(await xState(x))}`);
// 4. Pane themes: the first X tab gets Paper, the other tabs keep the app theme.
const label = await xs[0].evaluate(`window.__TAURI_INTERNALS__?.metadata?.currentWebview?.label ?? ''`);
const tabId = label.replace(/^tab-/, '');
await invoke('theme_scopes_set', { scopes: { ...original.scopes, panes: { ...original.scopes.panes, [tabId]: 'paper' } } });
await wait(2500);
for (const x of xs) log.push(`pane ${tabId}=paper ${JSON.stringify(await xState(x))}`);
snap('app-03-pane-theme');
} finally {
await invoke('theme_preview', { theme: null }).catch(() => {});
await invoke('theme_scopes_set', { scopes: original.scopes }).catch(error => log.push(`restore scopes failed ${error}`));
await invoke('theme_set', { theme: original.theme }).catch(error => log.push(`restore theme failed ${error}`));
if (!original.fonts.some(font => font.family === 'Space Grotesk')) await invoke('gfonts_remove', { family: 'Space Grotesk' }).catch(() => {});
await wait(2000);
for (const x of xs) log.push(`restored ${JSON.stringify(await xState(x))}`);
console.log(log.join('\n'));
shell.close(); xs.forEach(x => x.close());
}