// TODO 11 验收:设置 → 外观页的 1:1 几何 + 主题切换(即时生效 / 跨窗口同步 / 重启持久化), // 并抽查各已接表面的深浅一致性(无浅深混色、无可读性回归)。 // 真实隔离 YCode(真实 preload/IPC/渲染进程/文件系统)+ 原生设置窗口,视口 900×680(与原版一致)。 // // 原版依据(REA ev_dee8f184…/ev_24ab5853…/ev_ff4f073e…/ev_fa67fddb…): // .appearance-panel [232,40,638,640]、.appearance-preview [236,40,634,317]、 // 预览图 [308.906,86.281,488.172,270.719]、徽标「永久有效」[806,48,56,20]、 // 列表标题「全部主题」[236,381,56,20]、网格 [236,413,634,218.391]、卡片 [x,417,118.797,79.188]、 // 名称 [x,500.188,118.797,18];原版共 9 张卡,用户限定 YCode 只保留 浅色/深色 两张。 const fs = require('node:fs/promises') const path = require('node:path') const assert = require('node:assert/strict') const EXPECTED = { panel: [232, 40, 638, 640], preview: [236, 40, 634, 317], image: [308.906, 86.281, 488.172, 270.719], badge: [806, 48, 56, 20], headerTitle: [236, 381, 56, 20], grid: [236, 413, 634, 218.391], card: [118.797, 79.188], name: [118.797, 18] } if (!process.versions.electron) { ;(async () => { const directory = await fs.mkdtemp(path.resolve('.tmp/appearance-settings-')) 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 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('APPEARANCE_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 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 }) } const near = (value, target, tolerance = 0.6) => Math.abs(value - target) <= tolerance 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 layoutSource = `(()=>{ const rect = node => { const r = node.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height } } const round = value => Math.round(value * 1000) / 1000 const shape = node => { if (!node) return null; const r = rect(node); return { x: round(r.x), y: round(r.y), width: round(r.width), height: round(r.height) } } const panel = document.querySelector('.appearance-panel') const cards = [...document.querySelectorAll('.appearance-card-cell')] return { panel: shape(panel), preview: shape(document.querySelector('.appearance-preview')), previewBackground: (() => { const node = document.querySelector('.appearance-preview'); return node ? getComputedStyle(node).backgroundImage : '' })(), image: shape(document.querySelector('.appearance-preview__image')), badge: (() => { const node = document.querySelector('.appearance-badge--validity'); return node ? { ...shape(node), text: node.textContent.trim() } : null })(), header: shape(document.querySelector('.appearance-list__header')), headerTitle: (() => { const node = document.querySelector('.appearance-list__title'); return node ? { ...shape(node), text: node.textContent.trim() } : null })(), grid: shape(document.querySelector('.appearance-card-grid')), cards: cards.map(cell => ({ cell: shape(cell), card: shape(cell.querySelector('.appearance-card')), name: shape(cell.querySelector('.appearance-card__name')), label: cell.querySelector('.appearance-card__name')?.textContent?.trim(), selected: cell.querySelector('.appearance-card')?.classList.contains('appearance-card--selected'), variant: [...(cell.querySelector('.appearance-card')?.classList ?? [])].find(name => name.startsWith('appearance-card--')) })), imageSrc: document.querySelector('.appearance-preview__image')?.getAttribute('src') ?? '', theme: document.documentElement.dataset.theme ?? 'light', selectedOutline: (() => { const node = document.querySelector('.appearance-card--selected'); if (!node) return null const style = getComputedStyle(node); return [style.outlineStyle, style.outlineWidth, style.outlineColor] })() }})()` const themeSource = selectors => `(()=>{ const parse = value => { const match = /rgba?\\(([^)]+)\\)/.exec(value); if (!match) return null const parts = match[1].split(',').map(part => Number(part.trim())); return { r: parts[0], g: parts[1], b: parts[2], a: parts.length > 3 ? parts[3] : 1 } } const effective = node => { let current = node; while (current) { const color = parse(getComputedStyle(current).backgroundColor) if (color && color.a > 0.5) return color; current = current.parentElement } return { r: 255, g: 255, b: 255, a: 1 } } const luminance = color => { const channel = value => { const c = value / 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 } return 0.2126 * channel(color.r) + 0.7152 * channel(color.g) + 0.0722 * channel(color.b) } const contrast = (a, b) => { const l1 = luminance(a), l2 = luminance(b); const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1]; return (hi + 0.05) / (lo + 0.05) } const selectors = ${JSON.stringify(selectors)} const samples = {} for (const selector of selectors) { const node = document.querySelector(selector) if (!node) continue const style = getComputedStyle(node) const background = effective(node) const foreground = parse(style.color) ?? { r: 0, g: 0, b: 0, a: 1 } samples[selector] = { background, foreground, backgroundLuminance: Math.round(luminance(background) * 1000) / 1000, contrast: Math.round(contrast(background, foreground) * 100) / 100, hasText: (node.textContent ?? '').trim().length > 0 } } return { theme: document.documentElement.dataset.theme ?? 'light', samples } })()` const SETTINGS_SURFACES = ['.settings-window-root', '.settings-modal', '.settings-modal__nav', '.settings-modal__panel', '.appearance-card--selected', '.appearance-card__name'] const MAIN_SURFACES = ['.sidebar', '.chat-pane', '.wb-composer--home', '.detail-panel', '.sidebar-next'] ;(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('appearance-layout') await openSettings('appearance') const light = await read(layoutSource, settings) snapshots.light = light check('外观 nav entry is enabled and active', await read(`(()=>{const item=[...document.querySelectorAll('.settings-navigation__item')].find(node=>node.dataset.settingsTab==='appearance') return [item?.getAttribute('aria-disabled') !== 'true', document.querySelector('.settings-navigation__item--active')?.dataset.settingsTab]})()`, settings), [true, 'appearance']) check('panel keeps the recovered 232,40 638x640 box', [near(light.panel.x, EXPECTED.panel[0]), near(light.panel.y, EXPECTED.panel[1]), near(light.panel.width, EXPECTED.panel[2]), near(light.panel.height, EXPECTED.panel[3])], [true, true, true, true]) check('preview keeps the recovered 634x317 card and 488x271 image', [[near(light.preview.x, EXPECTED.preview[0]), near(light.preview.y, EXPECTED.preview[1]), near(light.preview.width, EXPECTED.preview[2]), near(light.preview.height, EXPECTED.preview[3])].every(Boolean), [near(light.image.x, EXPECTED.image[0], 1.5), near(light.image.y, EXPECTED.image[1], 1.5), near(light.image.width, EXPECTED.image[2], 1.5), near(light.image.height, EXPECTED.image[3], 1.5)].every(Boolean)], [true, true]) check('预览有效期徽标 keeps 永久有效 at the recovered 56x20 top-right', [light.badge.text, near(light.badge.x, EXPECTED.badge[0]), near(light.badge.y, EXPECTED.badge[1]), near(light.badge.width, EXPECTED.badge[2]), near(light.badge.height, EXPECTED.badge[3])], ['永久有效', true, true, true, true]) check('列表标题 keeps 全部主题 at the recovered 56x20 position', [light.headerTitle.text, near(light.headerTitle.x, EXPECTED.headerTitle[0]), near(light.headerTitle.y, EXPECTED.headerTitle[1])], ['全部主题', true, true]) // 原版网格 634×218.391 是 9 张卡两行;YCode 只保留 浅色/深色(1 行), // 因此这里断言列位置/宽度与单行高度(4px padding + 101.19 cell),并单独断言行位置。 check('header keeps the recovered 634x56 and the grid keeps its recovered origin', [near(light.header.x, 236), near(light.header.y, 357), near(light.header.width, 634), near(light.header.height, 56), near(light.grid.y, EXPECTED.grid[1])], [true, true, true, true, true]) // 原版两行 218.391;YCode 只有两张卡 = 一行:4px padding + 101.19 行高。 check('grid keeps the recovered column origin/width with a single row', [near(light.grid.x, EXPECTED.grid[0]), near(light.grid.width, EXPECTED.grid[2]), near(light.grid.height, 105.19, 2)], [true, true, true]) check('cards keep the recovered first-row cell geometry', light.cards.map((card, index) => [near(card.card.x, index === 0 ? 240 : 366.797), near(card.card.y, 417), near(card.card.width, EXPECTED.card[0]), near(card.card.height, EXPECTED.card[1])].every(Boolean)), [true, true]) check('keeps exactly the two requested theme cards with recovered card/label sizes', [light.cards.map(card => card.label), light.cards.map(card => [near(card.card.width, EXPECTED.card[0]), near(card.card.height, EXPECTED.card[1])].every(Boolean)), light.cards.map(card => [near(card.name.width, EXPECTED.name[0]), near(card.name.height, EXPECTED.name[1])].every(Boolean))], [['浅色', '深色'], [true, true], [true, true]]) check('first card is the selected light theme with the recovered outline', [light.cards[0].selected, light.cards[1].selected, light.selectedOutline[0], light.selectedOutline[1]], [true, false, 'solid', '2px']) await step('appearance-theme-switch') const darkSettings = await read(`(async()=>{const card=document.querySelectorAll('.appearance-card')[1] card.click() await new Promise(resolve => setTimeout(resolve, 500)) const image=document.querySelector('.appearance-preview__image') return { theme: document.documentElement.dataset.theme, stored: localStorage.getItem('ycode:theme'), selected: document.querySelector('.appearance-card--selected')?.classList.contains('appearance-card--dark'), src: image.getAttribute('src'), outline: getComputedStyle(document.querySelector('.appearance-card--selected')).outlineColor }})()`, settings) snapshots.darkSettings = darkSettings await pause(600) const darkLayout = await read(layoutSource, settings) snapshots.darkLayout = darkLayout const mainDark = await read(`(async()=>{const theme=document.documentElement.dataset.theme const sidebar=getComputedStyle(document.querySelector('.sidebar')).backgroundColor return { theme, sidebar }})()`) check('clicking 深色 switches the settings window immediately and persists shared storage', [darkSettings.theme, darkSettings.stored, darkSettings.selected], ['dark', 'dark', true]) check('main window follows the shared appearance (cross-window sync)', [mainDark.theme, mainDark.sidebar !== 'rgb(255, 255, 255)' && mainDark.sidebar !== 'rgb(247, 247, 247)'], ['dark', true]) check('preview swaps to the dark preview image and the selected ring turns light', [darkLayout.imageSrc !== light.imageSrc, darkLayout.selectedOutline[2]], [true, 'rgb(255, 255, 255)']) check('dark layout keeps the recovered panel geometry', [near(darkLayout.panel.width, EXPECTED.panel[2]), near(darkLayout.panel.height, EXPECTED.panel[3]), near(darkLayout.preview.height, EXPECTED.preview[3])], [true, true, true]) await step('appearance-theme-audit') const darkAudit = { settings: await read(themeSource(SETTINGS_SURFACES), settings), main: await read(themeSource(MAIN_SURFACES)) } snapshots.darkAudit = darkAudit const darkSurfaces = [...Object.entries(darkAudit.settings.samples), ...Object.entries(darkAudit.main.samples)] check('dark theme keeps every sampled surface dark and legible', darkSurfaces.map(([selector, sample]) => ({ selector, dark: sample.backgroundLuminance < 0.25, readable: !sample.hasText || sample.contrast >= 4.5 })), darkSurfaces.map(([selector]) => ({ selector, dark: true, readable: true }))) await read(`(async()=>{document.querySelectorAll('.appearance-card')[0].click() await new Promise(resolve => setTimeout(resolve, 400)); return true})()`, settings) await pause(500) const lightAudit = { settings: await read(themeSource(SETTINGS_SURFACES), settings), main: await read(themeSource(MAIN_SURFACES)) } snapshots.lightAudit = lightAudit const lightSurfaces = [...Object.entries(lightAudit.settings.samples), ...Object.entries(lightAudit.main.samples)] check('light theme keeps every sampled surface light and legible', lightSurfaces.map(([selector, sample]) => ({ selector, light: sample.backgroundLuminance > 0.7, readable: !sample.hasText || sample.contrast >= 4.5 })), lightSurfaces.map(([selector]) => ({ selector, light: true, readable: true }))) check('theme audit covers the recovered settings shell and the main window surfaces', [['.settings-window-root', '.settings-modal', '.settings-modal__nav', '.settings-modal__panel'].every(selector => selector in lightAudit.settings.samples), Object.keys(lightAudit.main.samples).length >= 2], [true, true]) check('preview card repaints its gradient between themes', [light.previewBackground !== darkLayout.previewBackground, /gradient/.test(light.previewBackground)], [true, true]) await step('appearance-persistence') await read(`(async()=>{document.querySelectorAll('.appearance-card')[1].click() await new Promise(resolve => setTimeout(resolve, 400)); return true})()`, settings) await pause(500) settings.close() await pause(600) await openSettings('appearance') const reopened = await read(layoutSource, settings) check('reopened settings window restores the persisted dark theme with the 深色 card selected', [reopened.theme, reopened.cards[1].selected, reopened.cards[0].selected], ['dark', true, false]) await read(`(async()=>{document.querySelectorAll('.appearance-card')[0].click() await new Promise(resolve => setTimeout(resolve, 400)); return true})()`, settings) await pause(400) const restored = await read(layoutSource, settings) check('selecting 浅色 restores the light theme and shared storage', [restored.theme, restored.cards[0].selected], ['light', true]) await step('appearance-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('APPEARANCE_SETTINGS_' + result + ' ' + directory) for (const failure of checks.filter(item => !item.pass)) console.log('APPEARANCE_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) }) }