// 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_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) }) }