feat(settings): replicate the WorkBuddy general settings panel (TODO 10)

This commit is contained in:
xiaohan.yuan committed 2026-10-17 08:53:34 +08:00
1 parent 401926d7eb
commit 7b68d9eb25
21 files changed
+1150 -11

No files matched your search

+12 -2
View File
@@ -138,7 +138,12 @@ async function buildBundle(tempRoot: string, state: TestState, name: string): Pr
const state = globalThis[${stateKey}];
export const app = {
getPath: (name) => name === 'userData' ? state.userData : name === 'home' ? state.home : state.userData,
getName: () => 'YCode', getVersion: () => '0.1.0'
getName: () => 'YCode', getVersion: () => '0.1.0',
isPackaged: false,
isReady: () => true,
getAppPath: () => state.userData,
getLoginItemSettings: () => ({ openAtLogin: false }),
setLoginItemSettings: () => {}
};
export const safeStorage = {
isEncryptionAvailable: () => false,
@@ -150,7 +155,12 @@ async function buildBundle(tempRoot: string, state: TestState, name: string): Pr
showOpenDialog: async (...args) => ({ canceled: false, filePaths: state.dialogPaths.splice(0) }),
showSaveDialog: async () => ({ canceled: true, filePath: undefined })
};
export const shell = { openPath: async () => '' };
export const shell = { openPath: async () => '', openExternal: async () => {} };
export const powerSaveBlocker = {
start: () => { state.powerBlockers = (state.powerBlockers ?? 0) + 1; return state.powerBlockers; },
stop: () => true,
isStarted: () => (state.powerBlockers ?? 0) > 0
};
export class BrowserWindow {}
export class WebContentsView {}
`
@@ -0,0 +1,11 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".settings-modal__panel",
".general-settings-panel"
],
"actions": [
{ "kind": "scroll", "step_id": "return-general-top", "selector": ".settings-modal__panel", "delta_y": -1000, "frames": 1 }
]
}
@@ -0,0 +1,17 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".settings-modal__panel",
".general-settings-panel",
".general-settings-panel__group",
".general-settings-panel__group-card",
".general-settings-panel__section"
],
"actions": [
{ "kind": "wait", "step_id": "general-top", "duration_ms": 250, "frames": 1 },
{ "kind": "scroll", "step_id": "general-scroll-1", "selector": ".settings-modal__panel", "delta_y": 400, "frames": 1 },
{ "kind": "scroll", "step_id": "general-scroll-2-to-bottom", "selector": ".settings-modal__panel", "delta_y": 400, "frames": 1 },
{ "kind": "scroll", "step_id": "return-general-top", "selector": ".settings-modal__panel", "delta_y": -1000, "frames": 1 }
]
}
@@ -0,0 +1,14 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".settings-modal__panel",
".general-settings-panel",
".general-settings-panel__group",
".general-settings-panel__section"
],
"actions": [
{ "kind": "click", "step_id": "select-general-tab", "selector": ".settings-navigation__item", "index": 0, "frames": 0 },
{ "kind": "wait", "step_id": "general-ready", "duration_ms": 500, "frames": 1 }
]
}
@@ -0,0 +1,16 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".user-menu-popover",
".user-menu-items",
".settings-modal__panel",
".general-settings-panel"
],
"actions": [
{ "kind": "click", "step_id": "open-account-menu", "selector": ".user-menu-trigger--workbuddy", "index": 0, "frames": 0 },
{ "kind": "wait", "step_id": "account-menu-ready", "duration_ms": 500, "frames": 0 },
{ "kind": "click", "step_id": "open-settings", "selector": ".user-menu-items .user-menu-item", "index": 3, "frames": 0 },
{ "kind": "wait", "step_id": "settings-ready", "duration_ms": 800, "frames": 1 }
]
}
+111
View File
@@ -0,0 +1,111 @@
// Recover only already-retained REA CSS; never access the installed application here.
//
// Source: REA evidence ev_377c8790468b3c67dad8577aabb327f98d3140659f0fffdbabfcff5a40cc9e94
// (WorkBuddy 5.8.x settings window), projected through the retained contexts stylesheet
// `.tmp/rea-css-recovered/contexts-BkjwYSZT.css`. The general settings panel DOM classes were
// observed in REA evidence ev_2309de12a003fca2c863a223cbda5bccad36b3af73e29bc309fdaed94745cf9e
// (settings window layout: `general-settings-panel`, `font-size-slider`, `wb-storage`,
// `power-save-blocker-settings`, `proxy-section`).
import fs from 'node:fs/promises'
import { createHash } from 'node:crypto'
import postcss from 'postcss'
const sha = value => createHash('sha256').update(value).digest('hex')
const sourceEvidenceId = 'ev_377c8790468b3c67dad8577aabb327f98d3140659f0fffdbabfcff5a40cc9e94'
const layoutEvidenceId = 'ev_2309de12a003fca2c863a223cbda5bccad36b3af73e29bc309fdaed94745cf9e'
const contexts = await fs.readFile('.tmp/rea-css-recovered/contexts-BkjwYSZT.css', 'utf8')
const contextsSha = sha(contexts)
if (contextsSha !== '50cbd673f7f35fc7f966bdddc6c9f5677fdfe944cb904888d214619fca259380') throw new Error('Contexts CSS changed')
const tokens = await fs.readFile('.tmp/rea-css-recovered/tokens-Df-i-zsO.css', 'utf8')
const tokensSha = sha(tokens)
if (tokensSha !== '13f2bf82c96e5167a7d7098e36950c6c22c475b3337dbb8bb29ad2fe79cde696') throw new Error('Tokens CSS changed')
// UI kit:设置窗口实际加载的控件样式表(按钮 / 输入框 / 开关 / 图标)。
const kit = await fs.readFile('.tmp/rea-css-recovered/src-Bh3YZ35H.css', 'utf8')
const kitSha = sha(kit)
if (kitSha !== '88dbdc1cd3f59d0e8b75824512a465a2be714bc7af481d790fe3a9484f85e01a') throw new Error('UI kit CSS changed')
// 选择器白名单:通用设置面板本体、字号滑块、存储卡片、电源与代理相关规则。
const panelled = /\.(general-settings-panel|font-size-slider|wb-storage|power-save-blocker-settings|proxy-section)(?![a-zA-Z0-9])/
function project(node) {
if (node.type === 'rule') {
const selectors = node.selectors.filter(selector => panelled.test(selector))
return selectors.length ? node.clone({ selector: selectors.join(',') }) : null
}
if (node.type === 'atrule' && node.nodes) {
const nodes = node.nodes.map(project).filter(Boolean)
return nodes.length ? node.clone({ nodes }) : null
}
return null
}
const parsed = postcss.parse(contexts)
const root = postcss.root({ nodes: parsed.nodes.map(project).filter(Boolean) })
// 原版的 .wb-button / .wb-input / .wb-switch / .wb-icon 来自全局 UI kit;YCode 里同样收敛到面板作用域。
// 用 :where() 前缀保持原版层叠:UI kit 规则与面板规则同权重,面板规则在后(原版面板 chunk 后加载),
// 例如 .wb-storage__open-folder{height:auto} 必须仍能覆盖 .wb-button--medium{height:32px}。
const kitScoped = /^\.wb-(button|input|switch|icon)(?![a-zA-Z])/
function projectKit(node) {
if (node.type === 'rule') {
const selectors = node.selectors.filter(selector => kitScoped.test(selector.trim()))
return selectors.length ? node.clone({ selector: selectors.map(selector => `:where(.general-settings-panel) ${selector}`).join(',') }) : null
}
if (node.type === 'atrule' && node.nodes) {
const nodes = node.nodes.map(projectKit).filter(Boolean)
return nodes.length ? node.clone({ nodes }) : null
}
return null
}
const kitRoot = postcss.root({ nodes: postcss.parse(kit).nodes.map(projectKit).filter(Boolean) })
// 原版把 `--wb-*` 设计变量放在全局 :root(tokens 样式表),YCode 的既有做法是按组件作用域
// 复制一份,避免全局污染。这里同样把 tokens 的 :root / 深色作用域合并成通用面板作用域。
const lightTokens = new Map()
const darkTokens = new Map()
for (const rule of postcss.parse(tokens).nodes) {
if (rule.type !== 'rule') continue
const selectors = rule.selectors ?? []
const dark = selectors.some(selector => selector.includes('[data-theme=dark]'))
const light = selectors.some(selector => selector.trim() === ':root')
if (!dark && !light) continue
for (const node of rule.nodes ?? []) {
if (node.type !== 'decl' || !node.prop.startsWith('--')) continue
if (dark) darkTokens.set(node.prop, node.value)
if (light) lightTokens.set(node.prop, node.value)
}
}
// UI kit 的控件 token 定义在它自己的样式表里。
for (const rule of postcss.parse(kit).nodes) {
if (rule.type !== 'rule') continue
const selectors = rule.selectors ?? []
const dark = selectors.some(selector => selector.includes('[data-theme=dark]'))
const light = selectors.some(selector => selector.trim() === ':root')
if (!dark && !light) continue
for (const node of rule.nodes ?? []) {
if (node.type !== 'decl' || !node.prop.startsWith('--')) continue
if (dark) darkTokens.set(node.prop, node.value)
if (light) lightTokens.set(node.prop, node.value)
}
}
if (lightTokens.size < 200 || darkTokens.size < 200) throw new Error('Tokens CSS lacks root scopes')
// 只输出面板规则真正引用到的变量(含 var() 传递闭包),避免把整套全局 token 复制进面板作用域。
const referenced = new Set()
const collect = (text) => { for (const match of text.matchAll(/var\(\s*(--[\w-]+)/g)) referenced.add(match[1]) }
collect(root.toString())
collect(kitRoot.toString())
for (let depth = 0; depth < 6; depth++) {
const before = referenced.size
for (const name of [...referenced]) {
const value = lightTokens.get(name) ?? darkTokens.get(name)
if (typeof value === 'string') collect(value)
}
if (referenced.size === before) break
}
const missing = [...referenced].filter(name => !lightTokens.has(name))
if (missing.length) console.log(`Tokens missing from light root scope: ${missing.join(', ')}`)
const pick = (map) => new Map([...referenced].filter(name => map.has(name)).map(name => [name, map.get(name)]))
const scope = (name, map) => `.${name}{${[...map].map(([property, value]) => `${property}:${value}`).join(';')}}`
const scopedTokens = `${scope('general-settings-panel', pick(lightTokens))}\n:root[data-theme=dark] ${scope('general-settings-panel', pick(darkTokens))}\n`
const css = `/* REA ${sourceEvidenceId} via contexts sha256=${contextsSha}; tokens sha256=${tokensSha}; ui-kit sha256=${kitSha}; general settings panel classes from REA ${layoutEvidenceId}. */\n${kitRoot.toString()}\n${root.toString()}\n${scopedTokens}`
await fs.writeFile('src/renderer/src/components/source-general-settings-recovered.css', css)
const rules = []
root.walkRules(rule => rules.push(rule.selector))
console.log(`Recovered ${rules.length} general-settings rules (${css.length} bytes)`)
console.log(rules.slice(0, 12).join('\n'))
if (rules.length < 60) throw new Error('Unexpectedly few general settings rules')
console.log(`Scoped ${pick(lightTokens).size} light / ${pick(darkTokens).size} dark referenced design tokens into .general-settings-panel`)
+312
View File
@@ -0,0 +1,312 @@
// TODO 10 验收:设置 → 通用面板的原版几何/排版 + YCode 真实能力接线 + 未接入项不伪装。
// 真实隔离 YCode(真实 preload/IPC/渲染进程/文件系统)+ 原生设置窗口,视口 900×680(与原版一致)。
//
// 原版依据(REA):
// - ev_2309de12a003fca2c863a223cbda5bccad36b3af73e29bc309fdaed94745cf9e(面板 DOM/几何/computed style/svg)
// - ev_a2dbeb23e30b9770743e857279a2e43da38fcc48d10612f75ace2938c5b29114(5.8.3 面板滚动采样 + 文案)
// 原版实测:面板 [232,40,638,1415];组 常规 218 / 权限 514 / 存储 265 / 通知 234 / 隐私 120;
// 行高 [44,68,52 | 56,68,68,76,68,68,56 | 56,68,56 | 66];卡片 bg #fff / radius 12 / padding 12px 16px;
// 标题 14px/500/22px;描述 14px/400/22px rgba(0,0,0,.5);开关 34×20;下拉 120×32;滑块 224×40。
const fs = require('node:fs/promises')
const path = require('node:path')
const assert = require('node:assert/strict')
const EXPECTED_TITLES = ['语言', '链接打开方式', '字体大小', '允许锁屏运行', '运行时防止系统休眠', '开机自动启动',
'网络代理', '自动安装可信技能', '技能自动更新', '套件自动更新', '桌面通知', '通知提示音', '定时任务产物通知', '体验优化计划']
const EXPECTED_ROW_HEIGHTS = [44, 68, 52, 56, 68, 68, 76, 68, 68, 56, 56, 68, 56, 66]
const EXPECTED_GROUPS = ['常规', '权限', '通知', '隐私']
const EXPECTED_PLACEHOLDERS = 11
if (!process.versions.electron) {
;(async () => {
const directory = await fs.mkdtemp(path.resolve('.tmp/general-settings-'))
const data = path.join(directory, 'data')
const home = path.join(directory, 'home')
const nextWorkspace = path.join(directory, 'workspace-root')
await fs.mkdir(data, { recursive: true })
await fs.mkdir(home, { recursive: true })
await fs.mkdir(nextWorkspace, { recursive: true })
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: home,
YCODE_DISABLE_GPU: '1', YCODE_DISABLE_SYSTEM_INTEGRATION: '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('GENERAL_SETTINGS_')) process.stdout.write(bytes) })
child.stderr.on('data', bytes => { output += bytes })
const timer = setTimeout(() => child.kill(), 300000)
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
const dataDir = process.env.YCODE_USER_DATA_DIR
const nextWorkspace = path.join(directory, 'workspace-root')
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 })
}
function configFile() {
try { return JSON.parse(require('node:fs').readFileSync(path.join(dataDir, 'config.json'), 'utf8')) } catch { return {} }
}
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')
settings.webContents.on('console-message', (_event, level, message) => { if (level >= 2) rendererErrors.push(message) })
await wait('!!document.querySelector(".settings-window-root")', 'settings root', settings)
await pause(400)
return settings
}
const panelSource = `(()=>{
const rect = node => { const r = node.getBoundingClientRect(); return [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)] }
const panel = document.querySelector('.general-settings-panel')
const groups = [...document.querySelectorAll('.general-settings-panel__group')]
const sections = [...document.querySelectorAll('.general-settings-panel__group-card > .general-settings-panel__section')]
const storage = document.querySelector('.wb-storage')
const styles = node => { const s = getComputedStyle(node); return { fontSize: s.fontSize, fontWeight: s.fontWeight, lineHeight: s.lineHeight,
color: s.color, background: s.backgroundColor, borderRadius: s.borderRadius, padding: s.padding, gap: s.gap, paddingLeft: s.paddingLeft } }
const placeholderNodes = [...document.querySelectorAll('.general-settings-panel [data-settings-unavailable=true]')]
return {
panel: panel ? rect(panel) : null,
spacing3: panel ? getComputedStyle(panel).getPropertyValue('--wb-spacing-3').trim() : null,
labelPaddingLeft: panel ? getComputedStyle(panel.querySelector('.general-settings-panel__group-label')).paddingLeft : null,
order: panel ? [...panel.children].map(node => node.className.split(' ')[0]) : [],
groups: groups.map(node => ({ label: node.querySelector('.general-settings-panel__group-label')?.textContent?.trim(),
bounds: rect(node), labelBounds: rect(node.querySelector('.general-settings-panel__group-label')),
card: styles(node.querySelector('.general-settings-panel__group-card')) })),
groupCount: groups.length,
titles: sections.map(node => node.querySelector('.general-settings-panel__section-title')?.textContent?.trim()),
rowBounds: sections.map(rect),
rowHeight: sections.map(node => Math.round(node.getBoundingClientRect().height)),
titleStyle: styles(sections[0].querySelector('.general-settings-panel__section-title')),
descStyle: styles(sections[1].querySelector('.general-settings-panel__section-desc')),
switchWrap: rect(document.querySelector('.general-settings-panel__switch-wrap')),
switchTrack: rect(document.querySelector('.wb-switch__track')),
selects: [...document.querySelectorAll('.general-settings-panel .wb-select')].map(rect),
slider: rect(document.querySelector('.font-size-slider')),
sliderLabels: [...document.querySelectorAll('.font-size-slider__label')].map(node => node.textContent.trim()),
notificationButton: (() => { const button = [...document.querySelectorAll('.general-settings-panel .wb-button')].find(node => node.textContent.includes('前往系统设置'))
return button ? { bounds: rect(button), text: button.textContent.trim(), icons: button.querySelectorAll('svg').length } : null })(),
storage: storage ? { bounds: rect(storage), label: storage.querySelector('.wb-storage__group-label')?.textContent?.trim(),
card: rect(storage.querySelector('.wb-storage__card')), cardTitle: storage.querySelector('.wb-storage__card-title')?.textContent?.trim(),
cache: (() => { const node = storage.querySelector('.wb-storage__cache'); return node ? rect(node) : null })(),
titleRow: (() => { const node = storage.querySelector('.wb-storage__card-title-row'); return node ? rect(node) : null })(),
cachePathRow: (() => { const node = storage.querySelector('.wb-storage__path-row'); return node ? rect(node) : null })(),
overview: (() => { const node = storage.querySelector('.general-settings-panel__storage-overview'); return node ? rect(node) : null })(),
legend: (() => { const node = storage.querySelector('.general-settings-panel__storage-legend'); return node ? rect(node) : null })(),
workspace: (() => { const node = storage.querySelector('.general-settings-panel__workspace'); return node ? rect(node) : null })(),
workspaceHeader: (() => { const node = storage.querySelector('.general-settings-panel__workspace-header'); return node ? rect(node) : null })(),
workspaceDesc: (() => { const node = storage.querySelector('.general-settings-panel__workspace-path-desc'); return node ? rect(node) : null })(),
openFolder: rect(storage.querySelector('.wb-storage__open-folder')), pathText: storage.querySelector('.wb-storage__path-text')?.textContent?.trim(),
usageText: storage.querySelector('.general-settings-panel__storage-usage-text')?.textContent?.replace(/\\s+/g, ' ').trim(),
legend: [...storage.querySelectorAll('.general-settings-panel__storage-legend-label')].map(node => node.textContent.trim()),
segments: [...storage.querySelectorAll('.general-settings-panel__storage-progress-seg')].map(node => Math.round(node.getBoundingClientRect().width * 100) / 100),
divider: rect(storage.querySelector('.wb-storage__divider')),
workspaceTitle: storage.querySelector('.general-settings-panel__workspace-path-title')?.textContent?.trim(),
workspaceTitleStyle: styles(storage.querySelector('.general-settings-panel__workspace-path-title')),
workspaceDesc: storage.querySelector('.general-settings-panel__workspace-path-desc')?.textContent?.trim(),
input: (() => { const node = storage.querySelector('.general-settings-panel__path-input')
return node ? { bounds: rect(node), value: node.value, readOnly: node.readOnly, style: styles(node) } : null })(),
change: (() => { const button = [...storage.querySelectorAll('.wb-button')].find(node => node.textContent.trim() === '更改')
return button ? { bounds: rect(button), text: button.textContent.trim() } : null })() } : null,
placeholders: placeholderNodes.length,
placeholderHints: placeholderNodes.map(node => [node.getAttribute('aria-disabled'), node.getAttribute('title')]),
placeholderControlsDisabled: placeholderNodes.every(node => { const input = node.querySelector('input, button'); return !input || input.tabIndex === -1 }),
inlineLink: (() => { const link = document.querySelector('.general-settings-panel__inline-link')
return link ? { text: link.textContent.trim(), disabled: link.getAttribute('aria-disabled'), title: link.getAttribute('title'), bounds: rect(link) } : null })(),
switchStates: [...document.querySelectorAll('.general-settings-panel__switch-wrap')].map(node => node.querySelector('input')?.checked),
unavailableSwitches: [...document.querySelectorAll('.general-settings-panel__switch-wrap[data-settings-unavailable=true] input')].map(node => node.hasAttribute('readonly'))
}})()`
;(async () => {
const sync = require('node:fs')
sync.mkdirSync(dataDir, { recursive: true })
app.setPath('userData', dataDir)
app.setPath('sessionData', dataDir)
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('general-panel-structure')
await openSettings('settings')
const nav = await read(`(()=>{const item=[...document.querySelectorAll('.settings-navigation__item')].find(node=>node.dataset.settingsTab==='settings')
return { enabled: item?.getAttribute('aria-disabled') !== 'true', active: !!document.querySelector('.settings-navigation__item--active') &&
document.querySelector('.settings-navigation__item--active').dataset.settingsTab === 'settings' }})()`, settings)
check('nav 通用 entry is enabled and active', [nav.enabled, nav.active], [true, true])
const panel = await read(panelSource, settings)
snapshots.panel = panel
check('panel keeps the recovered 232,40 638-wide column', [panel.panel[0], panel.panel[2]], [232, 638])
// 原版 1415 高;数据目录路径比原版长时该行会像原版一样换行,因此按实际行高换算期望值。
const pathRowOverflow = panel.storage.cachePathRow[3] - 22
check('panel keeps the recovered content height (path row wrapping accounted)',
[panel.panel[3], pathRowOverflow], [1415 + pathRowOverflow, pathRowOverflow >= 0 ? pathRowOverflow : -1])
check('panel top-level order stays group/group/storage/group/group',
panel.order.map(name => name.startsWith('general-settings-panel__group') ? 'group' : name), ['group', 'group', 'wb-storage', 'group', 'group'])
check('panel keeps the four recovered group labels', panel.groups.map(group => group.label), EXPECTED_GROUPS)
check('panel keeps the 14 recovered row titles in order', panel.titles, EXPECTED_TITLES)
check('panel keeps the recovered row heights', panel.rowHeight, EXPECTED_ROW_HEIGHTS)
check('group label keeps the recovered 638x22 with 8px left padding',
[panel.groups[0].labelBounds[2], panel.groups[0].labelBounds[3], panel.labelPaddingLeft, panel.spacing3], [638, 22, '8px', '8px'])
check('group card keeps the recovered padding and radius',
[panel.groups[1].card.padding, panel.groups[1].card.borderRadius, panel.groups[1].card.background], ['12px 16px', '12px', 'rgb(255, 255, 255)'])
check('row title keeps 14px/500/22px', [panel.titleStyle.fontSize, panel.titleStyle.fontWeight, panel.titleStyle.lineHeight], ['14px', '500', '22px'])
check('row description keeps 14px/400/22px tertiary',
[panel.descStyle.fontSize, panel.descStyle.fontWeight, panel.descStyle.lineHeight, panel.descStyle.color], ['14px', '400', '22px', 'rgba(0, 0, 0, 0.5)'])
check('switch keeps the recovered 34x20 control', [panel.switchTrack[2], panel.switchTrack[3]], [34, 20])
check('panel selects keep the recovered 120x32 geometry', panel.selects.map(size => [size[2], size[3]]),
panel.selects.map(() => [120, 32]))
check('font size slider keeps the recovered 224x40 with 小/默认/大 labels',
[panel.slider[2], panel.slider[3], panel.sliderLabels], [224, 40, ['小', '默认', '大']])
check('桌面通知 keeps the recovered 130x32 button with a trailing icon',
[panel.notificationButton.bounds[2], panel.notificationButton.bounds[3], panel.notificationButton.text, panel.notificationButton.icons],
[130, 32, '前往系统设置', 1])
await step('general-panel-storage')
const storage = panel.storage
check('storage keeps the recovered 638-wide card and legend wording',
[storage.bounds[2], storage.bounds[3], storage.label, storage.legend], [638, 265 + pathRowOverflow, '存储', ['系统缓存', '磁盘已用', '磁盘可用']])
check('storage keeps the recovered titles and helper texts',
[storage.cardTitle, storage.workspaceTitle, storage.workspaceDesc, storage.change.text],
['系统缓存目录', 'YCode 默认工作空间存储路径', '新建任务、工作空间时将自动存放在该路径下;修改后不影响已有数据。', '更改'])
check('storage keeps the recovered 76x22 打开目录 button', [storage.openFolder[2], storage.openFolder[3]], [76, 22])
check('storage keeps the recovered input and 更改 button geometry',
[[storage.input.bounds[2], storage.input.bounds[3]], [storage.change.bounds[2], storage.change.bounds[3]]], [[530, 32], [68, 32]])
check('storage input is read-only and filled', [storage.input.readOnly, storage.input.style.background, storage.input.style.borderRadius],
[true, 'rgb(242, 242, 242)', '8px'])
check('storage shows the real isolated data directory and disk usage',
[storage.pathText.startsWith(dataDir), /^已用 \d+(\.\d+)? [KMGT]?B \/ 共 \d+(\.\d+)? [KMGT]B$/.test(storage.usageText)], [true, true])
// 606 宽的进度条内部有 2 个 1px 间隙(原版 gap:1px),三段合计 604。
check('storage progress keeps the recovered 606x4 bar with 3 segments filling 604px',
[Math.round(storage.segments.reduce((sum, value) => sum + value, 0)), storage.segments.length], [604, 3])
check('storage divider stays 606x1', [storage.divider[2], storage.divider[3]], [606, 1])
check('workspace path title keeps 14px/500/22px',
[storage.workspaceTitleStyle.fontSize, storage.workspaceTitleStyle.fontWeight, storage.workspaceTitleStyle.lineHeight], ['14px', '500', '22px'])
await step('general-panel-placeholders')
check('unwired rows stay explicit placeholders with 暂未接入 hints',
[panel.placeholders, panel.placeholderHints.every(([disabled, title]) => disabled === 'true' && title === '暂未接入'), panel.placeholderControlsDisabled],
[EXPECTED_PLACEHOLDERS, true, true])
check('unwired switches stay read-only', panel.unavailableSwitches.every(Boolean), true)
check('了解更多 stays a disabled inline link', [panel.inlineLink.text, panel.inlineLink.disabled, panel.inlineLink.title], ['了解更多', 'true', '暂未接入'])
const placeholderClick = await read(`(()=>{const select=document.querySelector('.general-settings-panel .wb-select[aria-disabled=true]')
select.click()
const switchInput=document.querySelector('.general-settings-panel__switch-wrap[data-settings-unavailable=true] input')
const before=switchInput.checked; switchInput.click()
return { popover: !!document.querySelector('.wb-select-popover'), switchBefore: before, switchAfter: switchInput.checked }})()`, settings)
check('placeholder select does not open a popover and placeholder switch does not toggle',
[placeholderClick.popover, placeholderClick.switchBefore === placeholderClick.switchAfter], [false, true])
await step('general-settings-wired-behaviour')
const initial = await read('window.ycode.general.get()', settings)
snapshots.initial = { ...initial, dataDir: initial.dataDir }
check('general view reports the isolated data directory and default preferences',
[initial.dataDir === dataDir, initial.preventSleep, initial.autoLaunch, initial.preventSleepActive], [true, true, false, false])
await read(`(()=>{const input=${'document.querySelectorAll(".general-settings-panel__switch-wrap:not([data-settings-unavailable]) input")[0]'}
input.click(); return true})()`, settings)
await pause(600)
const afterSleep = await read('window.ycode.general.get()', settings)
snapshots.afterSleep = afterSleep
check('toggling 运行时防止系统休眠 persists the preference',
[afterSleep.preventSleep, configFile().preventSleep], [false, false])
await read(`(()=>{${'document.querySelectorAll(".general-settings-panel__switch-wrap:not([data-settings-unavailable]) input")[0]'}.click(); return true})()`, settings)
await pause(600)
const restoredSleep = await read('window.ycode.general.get()', settings)
check('prevent-sleep preference can be restored to on', [restoredSleep.preventSleep, configFile().preventSleep], [true, true])
await read(`(()=>{${'document.querySelectorAll(".general-settings-panel__switch-wrap:not([data-settings-unavailable]) input")[1]'}.click(); return true})()`, settings)
await pause(600)
const afterLaunch = await read('window.ycode.general.get()', settings)
snapshots.afterLaunch = afterLaunch
check('toggling 开机自动启动 persists the preference (system integration isolated)',
[afterLaunch.autoLaunch, configFile().autoLaunch], [true, true])
await read(`(()=>{${'document.querySelectorAll(".general-settings-panel__switch-wrap:not([data-settings-unavailable]) input")[1]'}.click(); return true})()`, settings)
await pause(600)
const restoredLaunch = await read('window.ycode.general.get()', settings)
check('auto-launch preference can be restored to off', [restoredLaunch.autoLaunch, configFile().autoLaunch], [false, false])
const changed = await read(`window.ycode.general.changeWorkspaceRoot(${JSON.stringify(nextWorkspace)})`, settings)
await pause(400)
const rootListed = await read(`(async()=>(await window.ycode.workspace.get()).root)()`, settings)
check('更改工作空间路径 persists the real workspace root',
[changed.workspaceRoot === nextWorkspace, rootListed === nextWorkspace], [true, true])
// 说明:「更改」按钮会打开系统目录选择器,无法自动化点击;这里断言按钮可用,
// 并在下面的重开窗口步骤里断言面板确实渲染持久化后的路径。
const changeButton = await read(`(()=>{const button=[...document.querySelectorAll('.wb-storage .wb-button')].find(node => node.textContent.trim() === '更改')
return { disabled: button.disabled, label: button.textContent.trim() }})()`, settings)
check('更改 button stays enabled and labelled', changeButton, { disabled: false, label: '更改' })
check('workspace root persists to config.json', configFile().workspaceRoot, nextWorkspace)
check('general API exposes the wired actions',
await read(`(()=>{const api=window.ycode.general; return [typeof api.openFolder, typeof api.changeWorkspaceRoot, typeof api.openSystemNotificationSettings]})()`, settings),
['function', 'function', 'function'])
await read('window.ycode.general.openSystemNotificationSettings()', settings)
check('notification settings call resolves while system integration is isolated', true, true)
await step('general-panel-persistence')
settings.close()
await pause(600)
await openSettings('settings')
const reopened = await read(panelSource, settings)
const reopenedView = await read('window.ycode.general.get()', settings)
snapshots.reopened = reopenedView
check('reopening the settings window keeps the persisted preferences',
[reopenedView.preventSleep, reopenedView.autoLaunch, reopenedView.workspaceRoot === nextWorkspace], [true, false, true])
check('reopened panel renders the persisted workspace path', reopened.storage.input.value === nextWorkspace, true)
check('reopened panel still renders the recovered rows', reopened.rowHeight, EXPECTED_ROW_HEIGHTS)
await step('general-panel-dark-theme')
const dark = await read(`(()=>{document.documentElement.dataset.theme='dark'
const card=document.querySelector('.general-settings-panel__group-card')
const title=document.querySelector('.general-settings-panel__section-title')
return { card: getComputedStyle(card).backgroundColor, title: getComputedStyle(title).color }})()`, settings)
check('dark theme repaints the panel card and titles', [dark.card !== 'rgb(255, 255, 255)', dark.title !== 'rgb(0, 0, 0)'], [true, true])
await read(`(()=>{document.documentElement.dataset.theme='light'; return true})()`, settings)
await step('general-panel-errors')
check('no renderer error logs', rendererErrors, [])
const result = checks.every(item => item.pass) ? 'PASS' : 'FAIL'
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result, phase, checks, snapshots }, null, 2))
console.log('GENERAL_SETTINGS_' + result + ' ' + directory)
for (const failure of checks.filter(item => !item.pass)) console.log('GENERAL_SETTINGS_FAILURE ' + JSON.stringify(failure))
app.exit(result === 'PASS' ? 0 : 1)
})().catch(async error => {
console.error(error)
try { await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'ERROR', phase, checks, snapshots, error: String(error?.stack ?? error) }, null, 2)) } catch { /* ignore */ }
app.exit(1)
})
}
+3 -2
View File
@@ -124,10 +124,11 @@ if (!process.versions.electron) {
[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])
const expectedDisabled = EXPECTED_TABS.filter(tab => tab !== 'models' && tab !== 'appearance')
// 通用(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('models and appearance stay enabled', nav.tabs.filter((tab, index) => !nav.disabled[index]), ['appearance', 'models'])
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"]');