waybetter/dev-tools/native-webview-stress.mjs
CRT 5c64f2f6f8 dev-tools: the generic development harnesses (perf probes, close check, tab and webview stress)
Moved from wayBetter for X; an app passes its own origin, process name, scripts and fixtures.
2026-09-30 14:48:06 +07:00

203 lines
11 KiB
JavaScript

// Exercise native WebView creation with actual site pages. Start the app's release build with
// WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=<port> and, preferably,
// WEBVIEW2_USER_DATA_FOLDER pointing to a fresh directory. Nothing is clicked on the site.
// Usage: SITE_PAGES=https://x.com/home,https://x.com/explore node dev-tools/native-webview-stress.mjs <port> [6,12,24] [app-pid]
// (the first page is opened in every tab; with the mixed route mode the second in every other tab)
import { writeFile, unlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const port = process.argv[2] ?? '9344';
const stages = (process.argv[3] ?? '6,12,24').split(',').map(Number);
const appPid = process.argv[4] ? Number(process.argv[4]) : null;
const memoryLimitMB = Number(process.env.WAYBETTER_STRESS_MEMORY_LIMIT_MB ?? 6144);
const sitePages = (process.env.SITE_PAGES ?? '').split(',').map(page => page.trim()).filter(Boolean);
if (!sitePages.length) throw new Error('SITE_PAGES is required (pages of the site to open, comma separated)');
const siteOrigin = new URL(sitePages[0]).origin;
const routeMode = process.env.WAYBETTER_STRESS_ROUTE_MODE ?? 'home';
if (!stages.length || stages.some((n, i) => !Number.isInteger(n) || n < 1 || n > 24 || (i && n <= stages[i - 1]))) {
throw new Error('Stages must increase from 1 to at most 24');
}
if (appPid !== null && (!Number.isInteger(appPid) || appPid < 1)) throw new Error('Invalid app PID');
if (!Number.isFinite(memoryLimitMB) || memoryLimitMB < 1) throw new Error('Invalid memory limit');
if (!['home', 'mixed-home-explore'].includes(routeMode)) throw new Error('Invalid route mode');
const run = promisify(execFile);
async function processSnapshot() {
if (!appPid) return null;
const command = `$all=Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId;$ids=@(${appPid});do{$n=@($all | Where-Object { $ids -contains $_.ParentProcessId -and $ids -notcontains $_.ProcessId } | Select-Object -ExpandProperty ProcessId);$ids+= $n}while($n.Count -gt 0);@(Get-Process -Id $ids -ErrorAction SilentlyContinue | Select-Object Id,ProcessName,CPU,WorkingSet64) | ConvertTo-Json -Compress`;
const { stdout } = await run('powershell', ['-NoProfile', '-Command', command], { timeout: 15_000 });
const parsed = JSON.parse(stdout);
return Array.isArray(parsed) ? parsed : [parsed];
}
function processDelta(before, after) {
if (!before || !after) return null;
const old = new Map(before.map(item => [item.Id, item.CPU ?? 0]));
const rows = after.map(item => ({
pid: item.Id, name: item.ProcessName,
cpuSeconds: Math.round(((item.CPU ?? 0) - (old.get(item.Id) ?? 0)) * 10) / 10,
memoryMB: Math.round((item.WorkingSet64 ?? 0) / 1024 / 1024),
})).sort((a, b) => b.cpuSeconds - a.cpuSeconds);
return {
totalCpuSeconds: Math.round(rows.reduce((sum, row) => sum + row.cpuSeconds, 0) * 10) / 10,
totalMemoryMB: rows.reduce((sum, row) => sum + row.memoryMB, 0),
top: rows.slice(0, 6),
};
}
const targets = async () => (await (await fetch(`http://127.0.0.1:${port}/json/list`)).json());
const target = (await targets()).find(item => item.type === 'page' && item.url.startsWith('http://tauri.localhost/'));
if (!target) throw new Error('Shell CDP target not found');
const socket = new WebSocket(target.webSocketDebuggerUrl);
const ready = new Promise(resolve => { socket.onopen = resolve; });
const pending = new Map();
let serial = 0;
socket.onmessage = event => {
const message = JSON.parse(event.data);
if (message.id) { pending.get(message.id)?.(message); pending.delete(message.id); }
};
await ready;
const send = (method, params = {}) => new Promise(resolve => {
const id = ++serial;
pending.set(id, resolve);
socket.send(JSON.stringify({ id, method, params }));
});
const evaluate = async (expression, timeoutMs = 45_000) => {
let timeout;
const result = await Promise.race([
send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }),
new Promise((_, reject) => { timeout = setTimeout(() => reject(new Error(`Shell unresponsive for ${timeoutMs / 1000} s`)), timeoutMs); }),
]).finally(() => clearTimeout(timeout));
if (result.result?.exceptionDetails) throw new Error(JSON.stringify(result.result.exceptionDetails).slice(0, 600));
return result.result?.result?.value;
};
const backup = join(tmpdir(), `waybetter-layout-before-stress-${process.pid}.json`);
let completed = false;
try {
const original = await evaluate(`(() => {
if (!window.__TAURI__?.event?.emit) throw new Error('Tauri event API unavailable');
const liveIds = () => [...document.querySelectorAll('.dv-tabs-container .dv-tab .bt-tab[data-panel-id]')]
.map(tab => tab.dataset.panelId);
const originalIds = liveIds();
if (new Set(originalIds).size !== originalIds.length) throw new Error('Duplicate live panel IDs');
const saved = JSON.parse(localStorage.getItem('bt.shell.layout.v1') || 'null');
if (Object.keys(saved?.panels ?? {}).length !== originalIds.length) {
throw new Error('Saved panel count does not match live tabs; wait for initial layout persistence');
}
window.__webStress = {
originalIds: new Set(originalIds),
initialCount: originalIds.length,
layout: localStorage.getItem('bt.shell.layout.v1'),
delays: [], longTasks: [], expected: performance.now() + 50,
};
const s = window.__webStress;
s.observer = new PerformanceObserver(list => {
for (const entry of list.getEntries()) s.longTasks.push(entry.duration);
});
s.observer.observe({ entryTypes: ['longtask'] });
s.interval = setInterval(() => {
const now = performance.now();
s.delays.push(Math.max(0, now - s.expected));
s.expected = now + 50;
}, 50);
return { initialCount: s.initialCount, layout: s.layout };
})()`);
// This file is a recovery copy if the shell dies before its tabs can be closed. It is deleted
// after a successful cleanup; its contents are never printed or committed.
await writeFile(backup, JSON.stringify({ layout: original.layout }), { mode: 0o600 });
console.log(JSON.stringify({ initialTabs: original.initialCount, recoveryFile: backup }));
let previous = 0;
let stopReason = null;
for (const count of stages) {
const beforeProcesses = await processSnapshot();
const added = count - previous;
const urls = Array.from({ length: added }, (_, index) =>
routeMode === 'mixed-home-explore' && (previous + index) % 2 === 1
? sitePages[1] ?? sitePages[0] : sitePages[0]);
const stage = await evaluate(`(async () => {
const s = window.__webStress;
const start = performance.now();
const calls = ${JSON.stringify(urls)}.map(url => window.__TAURI__.event.emit('tab-open', {
url, background: true,
}));
await Promise.all(calls);
const dispatchMs = performance.now() - start;
const expected = s.initialCount + ${count};
const deadline = performance.now() + 30_000;
while (document.querySelectorAll('.dv-tab').length < expected && performance.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 100));
}
await new Promise(resolve => setTimeout(resolve, 8_000));
const saved = JSON.parse(localStorage.getItem('bt.shell.layout.v1') || 'null');
const liveIds = [...document.querySelectorAll('.dv-tabs-container .dv-tab .bt-tab[data-panel-id]')]
.map(tab => tab.dataset.panelId);
const extraIds = liveIds.filter(id => !s.originalIds.has(id));
return {
requested: ${count}, routeMode: ${JSON.stringify(routeMode)}, tabs: liveIds.length, liveExtraIds: extraIds.length,
savedExtraIds: Object.keys(saved?.panels ?? {}).filter(id => !s.originalIds.has(id)).length,
dispatchMs: Math.round(dispatchMs),
maxDelayMs: Math.round(Math.max(0, ...s.delays)),
maxLongTaskMs: Math.round(Math.max(0, ...s.longTasks)),
};
})()`, 50_000);
const list = await targets();
stage.xTargets = list.filter(item => item.type === 'page' && item.url.startsWith(`${siteOrigin}/`)).length;
stage.processes = processDelta(beforeProcesses, await processSnapshot());
console.log(JSON.stringify(stage));
if (stage.tabs !== original.initialCount + count || stage.liveExtraIds !== count) {
throw new Error('New live tab count or panel ID count did not reach target');
}
previous = count;
if (stage.maxDelayMs > 1500 || (stage.processes?.totalMemoryMB ?? 0) > memoryLimitMB) {
stopReason = stage.maxDelayMs > 1500 ? 'shell timer lag exceeded 1.5 s' : `process working sets exceeded ${memoryLimitMB} MB`;
break;
}
}
if (stopReason) console.log(JSON.stringify({ stoppedAt: previous, reason: stopReason }));
completed = previous === stages.at(-1);
if (!completed) console.error(`Ramp incomplete: reached ${previous} of ${stages.at(-1)} requested site tabs`);
} finally {
try {
const cleaned = await evaluate(`(async () => {
const s = window.__webStress;
if (!s) return { error: 'probe missing' };
clearInterval(s.interval);
s.observer.disconnect();
await new Promise(resolve => setTimeout(resolve, 600));
const panelIds = () => [...document.querySelectorAll('.dv-tabs-container .dv-tab .bt-tab[data-panel-id]')]
.map(tab => tab.dataset.panelId);
const extras = panelIds().filter(id => !s.originalIds.has(id));
for (const id of extras) {
// Explicit missing IDs are ignored. Confirm each live ID before sending, then wait for
// that exact panel to disappear before closing the next one.
if (!panelIds().includes(id)) continue;
await window.__TAURI__.event.emit('tab-shortcut', { id, shortcut: 'close-tab' });
const deadline = performance.now() + 3000;
while (panelIds().includes(id) && performance.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 50));
}
if (panelIds().includes(id)) throw new Error('Panel close did not persist: ' + id);
}
await new Promise(resolve => setTimeout(resolve, 500));
const remainingIds = panelIds();
if (s.layout === null) localStorage.removeItem('bt.shell.layout.v1');
else localStorage.setItem('bt.shell.layout.v1', s.layout);
return { extraTabs: extras.length, remainingTabs: remainingIds.length,
expectedTabs: s.initialCount,
originalIdsRestored: remainingIds.length === s.originalIds.size && remainingIds.every(id => s.originalIds.has(id)),
layoutRestored: localStorage.getItem('bt.shell.layout.v1') === s.layout };
})()`, 90_000);
console.log(JSON.stringify({ cleanup: cleaned }));
if (cleaned.remainingTabs !== cleaned.expectedTabs || !cleaned.originalIdsRestored || !cleaned.layoutRestored) {
completed = false;
} else {
await unlink(backup);
}
} catch (error) {
completed = false;
console.error(`Cleanup failed: ${error.message}; recovery layout is at ${backup}`);
}
socket.close();
if (!completed) process.exitCode = 1;
}