Files
ycode/scripts/verify-settings-performance.cjs
T

185 lines
14 KiB
JavaScript

// Real built Electron/preload/IPC. Measures the selected page through a painted frame,
// not just the openSettings IPC return or the first loading-shell paint.
const fs = require('node:fs/promises')
const path = require('node:path')
const assert = require('node:assert/strict')
const { performance } = require('node:perf_hooks')
if (!process.versions.electron) {
;(async () => {
const directory = await fs.mkdtemp(path.resolve('.tmp/settings-performance-'))
const data = path.join(directory, 'data'), home = path.join(directory, 'home')
await fs.mkdir(data, { recursive: true }); await fs.mkdir(home, { recursive: true })
// Other tasks may build out/ concurrently. Verify one immutable build per run.
const buildRoot = path.join(directory, 'build', 'out')
await fs.cp(path.resolve('out'), buildRoot, { recursive: true })
await fs.cp(path.resolve('resources/model-metadata'), path.join(directory, 'build/resources/model-metadata'), { recursive: true })
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_QA_BUILD_ROOT: buildRoot }
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL; delete env.YCODE_DISABLE_GPU
if (process.env.YCODE_QA_RENDERER_URL) env.ELECTRON_RENDERER_URL = process.env.YCODE_QA_RENDERER_URL
const child = require('node:child_process').spawn(require('electron'), ['--disable-gpu-sandbox', __filename],
{ env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', bytes => { output += bytes; if (bytes.toString().includes('SETTINGS_PERFORMANCE')) process.stdout.write(bytes) })
child.stderr.on('data', bytes => { output += bytes })
const timer = setTimeout(() => child.kill(), 150000)
child.once('exit', async code => {
clearTimeout(timer); await fs.writeFile(path.join(directory, 'process.log'), output)
console.log(JSON.stringify({ directory, exitCode: code })); if (code) console.error(output.slice(-6000))
process.exitCode = code ? 1 : 0
})
})().catch(error => { console.error(error); process.exitCode = 1 })
} else {
const { app, BrowserWindow, ipcMain } = require('electron')
const { pathToFileURL } = require('node:url')
const directory = process.env.YCODE_QA_DIRECTORY
const buildRoot = process.env.YCODE_QA_BUILD_ROOT
const sync = require('node:fs')
sync.mkdirSync(process.env.YCODE_USER_DATA_DIR, { recursive: true })
app.setPath('userData', process.env.YCODE_USER_DATA_DIR); app.setPath('sessionData', process.env.YCODE_USER_DATA_DIR)
app.setPath('home', path.join(directory, 'home'))
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
const checks = [], samples = [], rendererErrors = [], documentLoads = []
let main, settings, phase = 'boot', sample
const read = (source, win = settings) => win.webContents.executeJavaScript(source)
async function wait(predicate, label) {
for (let i = 0; i < 300; i++) { if (await predicate()) return; await pause(10) }
throw Error('Timeout: ' + label)
}
function check(label, actual, expected = true) { checks.push({ label, actual, expected, pass: JSON.stringify(actual) === JSON.stringify(expected) }) }
async function step(label) { phase = label; await fs.writeFile(path.join(directory, 'phase.txt'), label) }
app.on('browser-window-created', (_event, win) => {
if (!main) return
win.webContents.on('did-start-loading', () => documentLoads.push({ windowId: win.id, at: performance.now() }))
win.webContents.on('console-message', event => { if (event.level === 'error') rendererErrors.push(event.message) })
win.on('show', () => { if (sample && sample.showMs === undefined) sample.showMs = +(performance.now() - sample.started).toFixed(2) })
})
const targets = {
settings: '.general-settings-panel:not(.personalization-panel):not(.extensions-settings-panel):not(.help-feedback-panel)',
model: '.models-settings-panel', appearance: '.appearance-panel', keyboardShortcuts: '.keyboard-shortcuts-panel',
personalization: '.personalization-panel', memory: '.memory-settings-panel', extensions: '.extensions-settings-panel',
helpFeedback: '[data-about-help-panel="helpFeedback"]', getHelp: '[data-about-help-panel="getHelp"]'
}
async function open(section, action, label = section ?? 'default') {
sample = { label, started: performance.now() }
await read(`window.ycode.app.openSettings(${JSON.stringify(section)}, ${JSON.stringify(action)})`, main)
await wait(() => { settings = BrowserWindow.getAllWindows().find(win => win !== main); return !!settings }, 'settings window')
const expected = !section ? 'settings' : section === 'models' ? 'model' : section
await wait(async () => !settings.webContents.isLoading() && settings.isVisible() && await read(`!!document.querySelector(${JSON.stringify(targets[expected])}) && !document.querySelector('.settings-panel-loading')`), 'selected panel: ' + expected)
sample.contentMs = +(performance.now() - sample.started).toFixed(2)
await read(`new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(()=>resolve(true))))`)
sample.frameMs = +(performance.now() - sample.started).toFixed(2)
const { started, ...result } = sample; samples.push(result); sample = undefined
return result
}
async function close() {
settings.close()
await wait(() => !settings.isVisible(), 'settings hidden')
await wait(async () => !await read(`document.querySelector('.settings-window-root')`), 'closed pages unmounted')
}
;(async () => {
await import(pathToFileURL(path.join(buildRoot, 'main/index.mjs')).href)
await app.whenReady()
await wait(() => { main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode'); return main && !main.webContents.isLoading() }, 'main window')
await wait(async () => await read(`!!document.querySelector('.user-menu-trigger--workbuddy')`, main), 'main app')
await pause(300)
await step('entry-budgets')
const html = await fs.readFile(path.join(buildRoot, 'renderer/settings.html'), 'utf8')
const assets = [...new Set([...html.matchAll(/(?:src|href)="\.\/([^"<>]+\.(?:js|css))"/g)].map(match => match[1]))]
const bytes = { js: 0, css: 0 }
for (const asset of assets) bytes[asset.endsWith('.js') ? 'js' : 'css'] += (await fs.stat(path.join(buildRoot, 'renderer', asset))).size
check('settings static scripts stay below 1MB', bytes.js < 1000000)
check('settings base and shared control styles stay below 300KB', bytes.css < 300000)
check('dedicated HTML has an immediate loading shell', html.includes('aria-label="正在加载设置"'))
check('main renderer cannot acknowledge settings readiness', await read(`window.ycode.app.settingsReady().then(()=>false,()=>true)`, main))
check('main renderer cannot acknowledge settings presentation', await read(`window.ycode.app.settingsRendered(1).then(()=>false,()=>true)`, main))
await step('default-general-first-open')
await open(undefined, undefined, 'cold-default-general')
const firstId = settings.id, firstPid = settings.webContents.getOSProcessId()
check('default settings opens 通用', await read(`document.querySelector('[data-settings-tab="settings"]').getAttribute('aria-current')`), 'page')
check('native settings uses its dedicated entry', new URL(settings.webContents.getURL()).pathname.endsWith('/settings.html'))
await close()
check('close hides and retains the native window', !settings.isDestroyed() && !settings.isVisible())
check('close disposes all page-local controls', await read(`document.querySelectorAll('input,textarea,[role="dialog"]').length`), 0)
const loadsBefore = documentLoads.length
await step('warm-reopen')
for (let i = 0; i < 4; i++) { await open(undefined, undefined, 'warm-default-' + i); await close() }
check('reopen keeps the same window and renderer process', [settings.id, settings.webContents.getOSProcessId()], [firstId, firstPid])
check('reopening never reloads the document', documentLoads.length, loadsBefore)
await step('all-pages-and-default-entry-points')
for (const section of Object.keys(targets)) await open(section, undefined, 'page-' + section)
await close()
await read(`document.querySelector('.user-menu-trigger--workbuddy').click()`, main)
await wait(async () => await read(`!!document.querySelector('.ycode-account-menu')`, main), 'account menu')
await read(`[...document.querySelectorAll('.ycode-account-menu .user-menu-item')].find(node=>node.querySelector('.user-menu-item-label')?.textContent==='设置').click()`, main)
await wait(async () => settings.isVisible() && await read(`!!document.querySelector(${JSON.stringify(targets.settings)})`), 'account menu opens general')
check('account menu 设置 opens 通用', await read(`document.querySelector('[data-settings-tab="settings"]').getAttribute('aria-current')`), 'page')
await close()
await read(`window.dispatchEvent(new KeyboardEvent('keydown',{key:',',ctrlKey:true,bubbles:true}))`, main)
await wait(async () => settings.isVisible() && await read(`!!document.querySelector(${JSON.stringify(targets.settings)})`), 'Ctrl+, opens general')
check('Ctrl+, opens 通用', await read(`document.querySelector('[data-settings-tab="settings"]').getAttribute('aria-current')`), 'page')
await step('model-new-and-close-cleans-forms')
await open('model', 'new', 'model-new')
await wait(async () => await read(`!!document.querySelector('.models-settings-panel__editor-overlay')`), 'new model editor')
check('explicit model/new still opens the model editor', await read(`document.querySelectorAll('.models-settings-panel__editor-overlay').length`), 1)
await read(`(() => {const field=document.querySelector('.models-settings-panel__editor-overlay input[type="password"]');if(!field)return false;Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(field,'settings-regression-unsaved-key');field.dispatchEvent(new Event('input',{bubbles:true}));return true})()`)
await close()
check('closing removes secret and unsaved editor controls', await read(`document.querySelectorAll('input,textarea').length`), 0)
await open('model')
check('reopen does not restore an unsaved model editor', await read(`!!document.querySelector('.models-settings-panel__editor-overlay')`), false)
await step('refresh-after-external-save')
await close()
const model = { id: 'settings-perf', name: 'Settings refresh regression', providerId: 'custom', model: 'settings-perf', baseUrl: 'http://127.0.0.1:1/v1', authMode: 'none', supportsToolCall: false, supportsImages: false, supportsReasoning: false }
await read(`window.ycode.models.save(${JSON.stringify(model)})`, main)
await open('model')
await wait(async () => await read(`document.querySelector('.models-settings-panel').textContent.includes('Settings refresh regression')`), 'fresh models on reopen')
check('reopen refreshes models saved in another window', await read(`document.querySelector('.models-settings-panel').textContent.includes('Settings refresh regression')`))
await read(`window.ycode.models.save(${JSON.stringify({ ...model, name: 'Updated settings regression' })}, true)`, main)
await wait(async () => await read(`document.querySelector('.models-settings-panel').textContent.includes('Updated settings regression')`), 'visible models refresh')
check('visible settings follows external model changes', await read(`document.querySelector('.models-settings-panel').textContent.includes('Updated settings regression')`))
await step('fast-navigation-before-ready')
settings.destroy()
await read(`Promise.all([window.ycode.app.openSettings('model','new'),window.ycode.app.openSettings('appearance')])`, main)
settings = BrowserWindow.getAllWindows().find(win => win !== main)
await wait(async () => settings.isVisible() && !settings.webContents.isLoading() && await read(`!!document.querySelector('.appearance-panel')`), 'latest navigation after startup')
check('latest startup request wins', await read(`!!document.querySelector('.appearance-panel')&&!document.querySelector('.models-settings-panel__editor-overlay')`))
check('concurrent startup creates one settings window', BrowserWindow.getAllWindows().filter(win => win !== main).length, 1)
settings.minimize()
await open('memory', undefined, 'restore-minimized')
check('open restores a minimized settings window', !settings.isMinimized())
await step('close-during-load-does-not-reappear')
settings.destroy()
const opening = read(`window.ycode.app.openSettings('settings')`, main)
await wait(() => { settings = BrowserWindow.getAllWindows().find(win => win !== main); return !!settings }, 'loading window')
settings.close(); await opening
await wait(() => !settings.webContents.isLoading(), 'closed startup load completes')
await pause(150)
check('late ready signals never reopen a closed window', !settings.isVisible())
await open(undefined, undefined, 'reopen-after-startup-close')
check('closed-during-load window remains reusable', await read(`!!document.querySelector(${JSON.stringify(targets.settings)})`))
await close()
await step('main-close-destroys-hidden-cache')
app.on('before-quit', event => event.preventDefault())
main.close()
await wait(() => main.isDestroyed() && settings.isDestroyed(), 'hidden settings disposed with main window')
check('hidden settings does not keep app alive after main closes', settings.isDestroyed())
check('no renderer errors', rendererErrors, [])
const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL', scope: 'Real isolated app, GPU enabled; no model/network calls. Frame timings include the selected panel and two animation frames, not just a loading shell.', rendererMode: process.env.ELECTRON_RENDERER_URL ? 'dev' : 'production', bytes, assets, samples, checks, rendererErrors }
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
console.log('SETTINGS_PERFORMANCE_' + report.result + ' ' + directory)
for (const item of checks.filter(item => !item.pass)) console.log('SETTINGS_PERFORMANCE_FAILURE ' + JSON.stringify(item))
console.log('SETTINGS_PERFORMANCE_TIMINGS ' + JSON.stringify(samples))
app.exit(report.result === 'PASS' ? 0 : 1)
})().catch(async error => {
await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({ phase, error: String(error), stack: error.stack, samples, checks, rendererErrors }, null, 2))
console.error(error); app.exit(1)
})
}