Files
ycode/scripts/verify-appearance-settings.cjs

281 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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_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)
})
}