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

187 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// TODO 16/17 验收:移除设置里的「助理 / 设备连接 / 数据管理 / 应用管理」入口,并保证旧导航目标不落空白页。
// 真实隔离 YCode(真实 preload/IPC/渲染进程)+ 原生设置窗口。
//
// 原版依据:REA `ev_2309de12a003fca2c863a223cbda5bccad36b3af73e29bc309fdaed94745cf9e`
// (WorkBuddy 5.8.3 设置窗口,viewport 记录于 .tmp/rea-assessment-settings-window/layout.json):
// 设置导航共 16 项、分 4 组(general 5 / feature 6 / dataSecurity 3 / about 2),
// 条目几何 190×32、x=12、y=111 起、组间距 76。
// 用户要求(TODO 16/17)在其中移除:助理(claw)、设备连接(deviceConnections)、数据管理(dataManagement)、
// 应用管理(buddyApps);其余条目保持原样(未接入的仍为禁用 + 「暂未接入」)。
const fs = require('node:fs/promises')
const path = require('node:path')
const assert = require('node:assert/strict')
const EXPECTED_TABS = ['settings', 'account', 'subscriptionUsage', 'appearance', 'keyboardShortcuts', 'personalization',
'memory', 'extensions', 'models', 'securityCenter', 'helpFeedback', 'getHelp']
const REMOVED_TABS = ['claw', 'deviceConnections', 'dataManagement', 'buddyApps']
if (!process.versions.electron) {
;(async () => {
const directory = await fs.mkdtemp(path.resolve('.tmp/settings-nav-'))
const data = path.join(directory, 'data')
const home = path.join(directory, 'home')
await fs.mkdir(data, { recursive: true })
await fs.mkdir(home, { recursive: true })
const now = Date.now()
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions: [{ id: 'settings-nav-session', title: 'Settings Nav',
cwd: home, createdAt: now, updatedAt: now + 1, messages: [{ id: 'settings-nav-user', role: 'user', content: '设置导航验收', createdAt: now, status: 'done' }] }] }))
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: home,
YCODE_DISABLE_GPU: '1' }
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
const child = require('node:child_process').spawn(require('electron'),
['--no-sandbox', '--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_NAV_')) process.stdout.write(bytes) })
child.stderr.on('data', bytes => { output += bytes })
const timer = setTimeout(() => child.kill(), 240000)
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(-4000))
process.exitCode = code ? 1 : 0
})
})().catch(error => { console.error(error); process.exitCode = 1 })
} else {
const { app, BrowserWindow } = require('electron')
const { pathToFileURL } = require('node:url')
const directory = process.env.YCODE_QA_DIRECTORY
app.setPath('home', path.join(directory, 'home'))
const checks = [], snapshots = {}, rendererErrors = []
let main, settings, phase = 'boot'
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
async function read(source, win = main) {
let timer
try {
return await Promise.race([win.webContents.executeJavaScript(source), new Promise((_, reject) => {
timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 120))), 15000)
})])
} finally { clearTimeout(timer) }
}
async function wait(source, label, win = main) {
for (let index = 0; index < 300; index++) { const value = await read(source, win); if (value) return value; await pause(40) }
throw Error('Timeout: ' + label)
}
async function step(label) { phase = label; await fs.writeFile(path.join(directory, 'phase.txt'), label) }
function check(label, actual, expected) {
const pass = typeof expected === 'function' ? expected(actual) : JSON.stringify(actual) === JSON.stringify(expected)
checks.push({ label, pass, actual, expected: typeof expected === 'function' ? expected.toString() : expected })
}
async function openSettings(section) {
await read(`window.ycode.app.openSettings(${JSON.stringify(section)})`)
for (let index = 0; index < 200; index++) {
settings = BrowserWindow.getAllWindows().find(win => win !== main)
if (settings && !settings.webContents.isLoading()) break
await pause(25)
}
assert.ok(settings, 'settings window')
await wait('!!document.querySelector(".settings-window-root")', 'settings root', settings)
await pause(300)
return settings
}
const navSource = `(()=>{const items=[...document.querySelectorAll('.settings-navigation__item')];
const nav=document.querySelector('.settings-modal__nav');
return {tabs:items.map(node=>node.dataset.settingsTab),labels:items.map(node=>node.textContent.trim()),
disabled:items.map(node=>node.getAttribute('aria-disabled')),titles:items.map(node=>node.getAttribute('title')),
groups:[...document.querySelectorAll('.settings-navigation__group-title')].map(node=>node.textContent.trim()),
dividers:document.querySelectorAll('.settings-navigation__group-divider').length,
nav:[nav?Math.round(nav.getBoundingClientRect().width):0,nav?nav.clientWidth:0,nav?nav.scrollHeight-nav.clientHeight:0],
geometry:items.map(node=>{const r=node.getBoundingClientRect();return [Math.round(r.x),Math.round(r.width),Math.round(r.height)]})}})()`
;(async () => {
// 导入前不做任何 await,避免 Electron 先进入 ready(主进程 bundle 顶层会调用 disableHardwareAcceleration)。
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)
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
await app.whenReady()
for (let index = 0; index < 200; index++) {
main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode')
if (main && !main.webContents.isLoading()) break
await pause(40)
}
assert.ok(main, 'main window')
main.webContents.on('console-message', (_event, level, message) => { if (level >= 2) rendererErrors.push(message) })
await wait('!!window.ycode', 'main renderer')
await pause(1200)
await step('settings-nav-entries')
await openSettings('model')
settings.webContents.on('console-message', (_event, level, message) => { if (level >= 2) rendererErrors.push(message) })
const nav = await read(navSource, settings)
snapshots.nav = nav
check('settings nav keeps the 12 remaining entries in order', nav.tabs, EXPECTED_TABS)
check('removed entries are gone from the DOM', REMOVED_TABS.map(tab => nav.tabs.includes(tab)), [false, false, false, false])
check('removed entries are gone from labels and search metadata', [nav.labels.join('|').includes('助理'), nav.labels.join('|').includes('设备连接'),
nav.labels.join('|').includes('数据管理'), nav.labels.join('|').includes('应用管理')], [false, false, false, false])
// 原版 16 项在 680 高窗口溢出,thin 滚动条占 10px,故条目实测 190 = 200 - 10;
// 移除 4 项后本窗口不再溢出,条目宽度应等于导航栏内容宽(原版 CSS 无 scrollbar-gutter)。
const navContentWidth = nav.nav[1]
check('nav keeps the recovered 32px entry geometry at x=12 and fills the nav content width',
[nav.geometry.every(([x, width, height]) => x === 12 && height === 32), nav.geometry.every(([, width]) => width === navContentWidth), nav.nav[2] === 0], [true, true, true])
check('nav width reflects the recovered 200px column with a thin scrollbar only when overflowing',
[nav.nav[0] === 200 || nav.nav[0] === 190, nav.nav[2] === 0 ? navContentWidth === 200 : navContentWidth === 190], [true, true])
// 原版 4 组;about 组按组件约定渲染为分隔线而不是标题。
check('nav keeps the four recovered groups', [nav.groups.length, nav.dividers], [3, 1])
// 通用(settings) 在 TODO 10 接入后同样可用。
const expectedDisabled = EXPECTED_TABS.filter(tab => !['settings', 'models', 'appearance'].includes(tab))
check('unwired entries stay disabled', nav.tabs.filter((tab, index) => nav.disabled[index] === 'true'), expectedDisabled)
check('unwired entries keep the 暂未接入 hint', nav.titles.filter(title => (title ?? '').includes('暂未接入')).length, expectedDisabled.length)
check('settings, models and appearance stay enabled', nav.tabs.filter((tab, index) => !nav.disabled[index]), ['settings', 'appearance', 'models'])
await step('settings-search-excludes-removed')
await read(`(()=>{const input=document.querySelector('.settings-navigation input[type="search"]');
if(!input)throw new Error('missing search');Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(input,'助理');
input.dispatchEvent(new Event('input',{bubbles:true}));return true})()`, settings)
await pause(240)
snapshots.search = await read(`(()=>{const results=[...document.querySelectorAll('.settings-navigation__item')];
return {tabs:results.map(node=>node.dataset.settingsTab),empty:!!document.querySelector('.source-settings-search-empty')}})()`, settings)
check('searching the removed 助理 entry finds nothing', [snapshots.search.tabs, snapshots.search.empty], [[], true])
await read(`(()=>{const input=document.querySelector('.settings-navigation input[type="search"]');
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(input,'');input.dispatchEvent(new Event('input',{bubbles:true}));return true})()`, settings)
await pause(200)
await step('settings-navigation-still-works')
await read(`(()=>{const node=document.querySelector('[data-settings-tab="appearance"]');node.click();return true})()`, settings)
await wait(`!!document.querySelector('.segmented')`, 'appearance panel', settings)
check('appearance entry still opens its panel', await read(`!!document.querySelector('.segmented')&&!document.querySelector('.models-settings-panel')`, settings), true)
await read(`(()=>{const node=document.querySelector('[data-settings-tab="models"]');node.click();return true})()`, settings)
await wait(`!!document.querySelector('.models-settings-panel')`, 'models panel', settings)
check('models entry still opens its panel', await read(`!!document.querySelector('.models-settings-panel')`, settings), true)
await step('stale-navigation-falls-back')
await read(`(()=>{const node=document.querySelector('[data-settings-tab="securityCenter"]');node.click();return true})()`, settings)
await pause(200)
snapshots.unwiredClick = await read(`({alert:(document.querySelector('.settings-modal__panel [role="alert"]')||{}).textContent||'',models:!!document.querySelector('.models-settings-panel')})`, settings)
check('unwired entry reports 该设置尚未接入 instead of a blank page', snapshots.unwiredClick.alert.includes('该设置尚未接入'), true)
// 旧深链接指向已移除入口:应回退到模型设置。
await read(`window.ycode.app.openSettings('claw')`)
await pause(400)
check('stale navigation to 助理 falls back to the models panel', await read(`!!document.querySelector('.models-settings-panel')`, settings), true)
const previous = settings.id
await read(`document.querySelector('.settings-window-close').click()`, settings)
for (let index = 0; index < 200 && !settings.isDestroyed(); index++) await pause(20)
check('settings window closed before the deep-link relaunch', settings.isDestroyed(), true)
// 新窗口直接以已移除的 section 启动:同样不能是空白页。
await openSettings('dataManagement')
check('fresh window with a removed section lands on the models panel', await read(`!!document.querySelector('.models-settings-panel')`, settings), true)
check('fresh window reuses a new settings window', settings.id !== previous, true)
const finalNav = await read(navSource, settings)
snapshots.finalNav = finalNav
check('nav still excludes removed entries after relaunch', finalNav.tabs, EXPECTED_TABS)
check('no renderer error logs', rendererErrors, [])
const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL',
scope: 'Real isolated YCode App/preload/IPC/renderer + native settings window; navigation entries, geometry, search, disabled hints and stale-target fallback measured live.',
sourceEvidence: ['ev_2309de12a003fca2c863a223cbda5bccad36b3af73e29bc309fdaed94745cf9e'], snapshots, checks, rendererErrors }
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
console.log('SETTINGS_NAV_' + report.result + ' ' + directory)
for (const failure of checks.filter(item => !item.pass)) console.log('SETTINGS_NAV_FAILURE ' + JSON.stringify(failure))
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, checks, snapshots, rendererErrors }, null, 2))
console.error(error); app.exit(1)
})
}