// 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', 'keyboardShortcuts', 'personalization', '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, agents, models, about and help stay enabled', nav.tabs.filter((tab, index) => !nav.disabled[index]), ['settings', 'appearance', 'keyboardShortcuts', 'personalization', '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.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) }) }