Recover the WorkBuddy 5.8.3 个性化 page from REA evidence (PersonalizationPanel-D16rRGjy.css + tokens): 45 module rules plus scoped light/dark tokens, the five-card 1160px layout (identity card 638x100 with inline editors, reply preference card 638x204, three 638x264 long-text cards with 598x160 previews) and the 640x520 editor modal. Persist identity, tone, welcome tips, custom instructions, persona and long-term memory in config.json, expose personalization:get/update over IPC, and append the resulting personalization context to each turn's prompt for both the OMP and built-in kernels. Wire 欢迎语 to the streaming loading tips and keep 显示文件修改过程 visibly disabled because YCode has no incremental file-change renderer yet. Verify with scripts/verify-personalization-settings.cjs: 29 assertions covering geometry, tone options, switches, inline identity editing with blur-discard, editor modal save/cancel, config persistence, reopen, dark surfaces and no renderer errors. Add a pure prompt-builder regression and keep every existing settings regression green.
187 lines
13 KiB
JavaScript
187 lines
13 KiB
JavaScript
// 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'].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, keyboard shortcuts, personalization and models stay enabled', nav.tabs.filter((tab, index) => !nav.disabled[index]), ['settings', 'appearance', 'keyboardShortcuts', 'personalization', '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('.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)
|
||
})
|
||
}
|