// 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'] const HELP_FEEDBACK_ICON_PATH = 'M7.1 1.2Q9.1261 1.2 10.7207 2.4413L11.4578 1.4943Q9.5381 0 7.1 0Q4.1591 0 2.0796 2.0796Q0 4.1591 0 7.1Q0 10.0409 2.0796 12.1205Q4.1591 14.2 7.1 14.2Q10.0409 14.2 12.1205 12.1205Q14.2 10.0409 14.2 7.1Q14.2 4.8675 12.9272 3.0426L11.9429 3.7291Q13 5.2447 13 7.1Q13 9.5439 11.2719 11.2719Q9.5439 13 7.1 13Q4.6561 13 2.9281 11.2719Q1.2 9.5439 1.2 7.1Q1.2 4.6561 2.9281 2.9281Q4.6561 1.2 7.1 1.2ZM7.6986 3.1008L7.6986 4.3008L6.4986 4.3008L6.4986 3.1008L7.6986 3.1008ZM6.4986 5.6011L6.4986 11.1011L7.6986 11.1011L7.6986 5.6011L6.4986 5.6011Z' 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_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)]}), icons:items.map(node=>{const svg=node.querySelector('.settings-navigation__icon svg'),path=svg?.querySelector('path'); const r=svg?.getBoundingClientRect();return {svg:!!svg,width:r?.width??0,height:r?.height??0,viewBox:svg?.getAttribute('viewBox')??'', fill:svg?.getAttribute('fill')??'',d:path?.getAttribute('d')??'',pathFill:path?.getAttribute('fill')??'', transform:path?.getAttribute('transform')??''}})}})()` ;(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) const helpFeedbackIcon = nav.icons[nav.tabs.indexOf('helpFeedback')] check('about navigation keeps the fixed 5.8.3 info-circle icon instead of an empty SVG', [helpFeedbackIcon?.svg, helpFeedbackIcon?.width, helpFeedbackIcon?.height, helpFeedbackIcon?.viewBox, helpFeedbackIcon?.fill, helpFeedbackIcon?.pathFill, helpFeedbackIcon?.transform, helpFeedbackIcon?.d], [true, 16, 16, '0 0 16 16', 'none', 'currentColor', 'matrix(1 0 0 1 0.899994 0.899994)', HELP_FEEDBACK_ICON_PATH]) 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', 'keyboardShortcuts', 'personalization', 'memory', 'extensions', 'helpFeedback', 'getHelp'].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, appearance, shortcuts, personalization, memory, agents, models, about and help stay enabled', nav.tabs.filter((tab, index) => !nav.disabled[index]), ['settings', 'appearance', 'keyboardShortcuts', 'personalization', 'memory', 'extensions', 'models', 'helpFeedback', 'getHelp']) 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('.appearance-panel')`, 'appearance panel', settings) check('appearance entry still opens its panel', await read(`!!document.querySelector('.appearance-panel')&&!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.isVisible(); index++) await pause(20) check('settings window hides before the deep-link relaunch', !settings.isDestroyed() && !settings.isVisible(), true) // 复用窗口以已移除的 section 启动:同样不能是空白页。 await openSettings('dataManagement') check('reopened window with a removed section lands on the models panel', await read(`!!document.querySelector('.models-settings-panel')`, settings), true) check('reopening keeps the cached 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'], visualEvidence: [{ type: 'ycode-before', path: '.tmp/agents-settings-YLCzhi/ycode-agents-light.png', observation: 'about navigation label has no icon while getHelp has a question-mark icon' }, { type: 'ycode-before-crop', path: '.tmp/n06-ycode-settings-nav-about-missing.png', observation: 'cropped current YCode settings navigation shows the missing About icon' }, { type: 'workbuddy-reference-crop', path: '.tmp/n06-workbuddy-settings-nav-about.png', observation: 'WorkBuddy reference shows the info-circle icon beside About and the question icon beside Get Help' }, { type: 'rea-fixed', evidenceId: 'ev_2309de12a003fca2c863a223cbda5bccad36b3af73e29bc309fdaed94745cf9e', observation: 'helpFeedback SVG child path at 16x16 is the info-circle glyph' }], 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) }) }