feat(settings): replicate the WorkBuddy appearance panel and dark theme (TODO 11)
Recover the WorkBuddy 5.8.3 appearance page from REA evidence (appearance-DMmhwsxq.css + tokens-Df-i-zsO.css): 44 recovered .appearance-* rules, scoped light/dark tokens, original geometry (panel [232,40,638,640], preview 634x317, grid 634x218.391, cards 118.797x79.188) and the two REA-exported preview images with provenance sha256. Wire the panel into SettingsDialog with only the requested light/dark cards, add scoped tokens to the settings shell so the dark theme no longer renders a white window with near-white text, and verify with scripts/verify-appearance-settings.cjs (21 assertions: geometry, instant switch, cross-window sync, persistence, dark/light surface sampling with contrast >= 4.5, no renderer errors). Update the legacy nav/account/desktop appearance verifiers to the new .appearance-card selectors.
This commit is contained in:
1 parent
7b68d9eb25
commit
b36b52831b
25 files changed
+727
-21
No files matched your search
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"include_fonts": false,
|
||||
"include_message_content": false,
|
||||
"track_selectors": [
|
||||
"html",
|
||||
"body",
|
||||
".settings-modal",
|
||||
".settings-navigation",
|
||||
".settings-navigation__item",
|
||||
".settings-modal__panel",
|
||||
"[class*=appearance]"
|
||||
],
|
||||
"actions": [
|
||||
{ "kind": "click", "step_id": "select-appearance-tab", "selector": ".settings-navigation__item[data-settings-tab=\"appearance\"]", "index": 0, "frames": 0 },
|
||||
{ "kind": "wait", "step_id": "appearance-ready", "duration_ms": 600, "frames": 2 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"include_fonts": false,
|
||||
"include_message_content": false,
|
||||
"track_selectors": [
|
||||
"html",
|
||||
"body",
|
||||
".settings-window-root",
|
||||
".settings-navigation__item--active",
|
||||
".settings-modal__panel",
|
||||
".general-settings-panel",
|
||||
".general-settings-panel__group-card",
|
||||
".general-settings-panel__section-title",
|
||||
".general-settings-panel__section-desc",
|
||||
".wb-select",
|
||||
".wb-switch"
|
||||
],
|
||||
"actions": [
|
||||
{ "kind": "click", "step_id": "select-general-dark", "selector": ".settings-navigation__item", "index": 0, "frames": 0 },
|
||||
{ "kind": "wait", "step_id": "general-dark-ready", "duration_ms": 500, "frames": 1 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"include_fonts": false,
|
||||
"include_message_content": false,
|
||||
"track_selectors": [
|
||||
"html",
|
||||
"body",
|
||||
".settings-window-root",
|
||||
".settings-navigation__item--active",
|
||||
".settings-modal__panel",
|
||||
".general-settings-panel",
|
||||
".general-settings-panel__group-card",
|
||||
".general-settings-panel__section-title",
|
||||
".general-settings-panel__section-desc",
|
||||
".wb-select",
|
||||
".wb-switch"
|
||||
],
|
||||
"actions": [
|
||||
{ "kind": "click", "step_id": "select-general-light", "selector": ".settings-navigation__item", "index": 0, "frames": 0 },
|
||||
{ "kind": "wait", "step_id": "general-light-ready", "duration_ms": 500, "frames": 1 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"include_fonts": false,
|
||||
"include_message_content": false,
|
||||
"track_selectors": [
|
||||
"html",
|
||||
"body",
|
||||
".settings-window-root",
|
||||
".settings-navigation__item--active",
|
||||
".settings-modal__panel",
|
||||
".appearance-panel"
|
||||
],
|
||||
"actions": [
|
||||
{ "kind": "click", "step_id": "select-appearance-dark", "selector": ".settings-navigation__item[data-settings-tab=\"appearance\"]", "index": 0, "frames": 0 },
|
||||
{ "kind": "wait", "step_id": "appearance-dark-ready", "duration_ms": 500, "frames": 1 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"include_fonts": false,
|
||||
"include_message_content": false,
|
||||
"media_selectors": [".appearance-preview__image"],
|
||||
"actions": [
|
||||
{ "kind": "wait", "step_id": "preview-media", "duration_ms": 400, "frames": 0 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"include_fonts": false,
|
||||
"include_message_content": false,
|
||||
"track_selectors": [],
|
||||
"actions": [
|
||||
{ "kind": "click", "step_id": "restore-light-theme", "selector": ".appearance-card--light", "index": 0, "frames": 0 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"include_fonts": false,
|
||||
"include_message_content": false,
|
||||
"track_selectors": [],
|
||||
"actions": [
|
||||
{ "kind": "click", "step_id": "select-dark-theme", "selector": ".appearance-card--dark", "index": 0, "frames": 0 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"include_fonts": false,
|
||||
"include_message_content": false,
|
||||
"track_selectors": [
|
||||
"html",
|
||||
"body",
|
||||
".settings-window-root",
|
||||
".settings-window-chrome",
|
||||
".settings-modal",
|
||||
".settings-navigation",
|
||||
".settings-navigation__item--active",
|
||||
".settings-modal__panel",
|
||||
".appearance-preview",
|
||||
".appearance-badge",
|
||||
".appearance-list__title",
|
||||
".appearance-card--selected"
|
||||
],
|
||||
"actions": [
|
||||
{ "kind": "wait", "step_id": "dark-theme-stable", "duration_ms": 800, "frames": 4 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"include_fonts": false,
|
||||
"include_message_content": false,
|
||||
"track_selectors": [
|
||||
"html",
|
||||
"body",
|
||||
".settings-window-root",
|
||||
".settings-window-chrome",
|
||||
".settings-modal",
|
||||
".settings-navigation",
|
||||
".settings-navigation__item--active",
|
||||
".settings-modal__panel",
|
||||
".appearance-preview",
|
||||
".appearance-badge",
|
||||
".appearance-list__title",
|
||||
".appearance-card--selected"
|
||||
],
|
||||
"actions": [
|
||||
{ "kind": "wait", "step_id": "light-theme-restored", "duration_ms": 500, "frames": 2 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"include_fonts": false,
|
||||
"include_message_content": false,
|
||||
"track_selectors": [
|
||||
"html",
|
||||
"body",
|
||||
".settings-window-root",
|
||||
".settings-window-chrome",
|
||||
".settings-modal",
|
||||
".settings-navigation",
|
||||
".settings-navigation__item--active",
|
||||
".settings-modal__panel",
|
||||
".appearance-preview",
|
||||
".appearance-badge",
|
||||
".appearance-list__title",
|
||||
".appearance-card--selected"
|
||||
],
|
||||
"actions": [
|
||||
{ "kind": "click", "step_id": "select-dark-theme", "selector": ".appearance-card--dark", "index": 0, "frames": 1 },
|
||||
{ "kind": "wait", "step_id": "dark-theme-settled", "duration_ms": 800, "frames": 4 },
|
||||
{ "kind": "click", "step_id": "restore-light-theme", "selector": ".appearance-card--light", "index": 0, "frames": 0 },
|
||||
{ "kind": "wait", "step_id": "light-theme-restored", "duration_ms": 500, "frames": 2 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Recover only already-retained REA CSS; never access the installed application here.
|
||||
//
|
||||
// Source: REA evidence ev_24ab58532d4ece0d79f35c8c7d4cb14f5611a7405cdf747c4698e6184bea7d99
|
||||
// (WorkBuddy 5.8.3 设置 → 外观), 模块样式表 appearance-DMmhwsxq.css(原版懒加载,只有进入外观页才请求)。
|
||||
// 面板 DOM 与几何来自同一批 REA 证据 `.tmp/todo11-wb-appearance/layout.json`:
|
||||
// .appearance-panel [232,40,638,640]、.appearance-preview 634×317、卡片网格 5 列 118.8×101.2、卡片 118.8×79.2。
|
||||
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 evidenceId = 'ev_24ab58532d4ece0d79f35c8c7d4cb14f5611a7405cdf747c4698e6184bea7d99'
|
||||
const evidence = JSON.parse(await fs.readFile(`.tmp/rea-css-evidence/${evidenceId}.json`, 'utf8'))
|
||||
if (evidence.evidence_id !== evidenceId) throw new Error('REA identity mismatch')
|
||||
const module = evidence.normalized_result.styles.items.find(item => /appearance-DMmhwsxq\.css$/.test(item.file_path))
|
||||
if (!module) throw new Error('Appearance module CSS is absent from the evidence')
|
||||
const moduleCss = module.source.artifact.text
|
||||
const moduleSha = sha(moduleCss)
|
||||
if (moduleSha !== 'af58cb3134d3e6c474424f6d718daa8f5be0c6e29d40aa5f9f80c14b3ffbbbd2') throw new Error('Appearance module 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')
|
||||
|
||||
// 原版外观面板自己的 token 定义(--wb-text-strong 等)在 tokens 样式表里,按组件作用域复制一份。
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 只保留外观面板相关规则;同时把原版的深色选择器(.dark / [data-cb-theme=dark])改写成 YCode 的 [data-theme=dark]。
|
||||
const darkSelector = /(^|[\s,>+~])\.dark\b|\[data-cb-theme=dark\]/g
|
||||
function project(node) {
|
||||
if (node.type === 'rule') {
|
||||
const selectors = []
|
||||
for (const selector of node.selectors) {
|
||||
if (!/\.appearance-/.test(selector)) continue
|
||||
const mapped = selector.replace(darkSelector, (match, prefix) => `${prefix ?? ''}[data-theme=dark]`)
|
||||
if (!selectors.includes(mapped)) selectors.push(mapped)
|
||||
}
|
||||
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 root = postcss.root({ nodes: postcss.parse(moduleCss).nodes.map(project).filter(Boolean) })
|
||||
const referenced = new Set()
|
||||
const collect = (text) => { for (const match of text.matchAll(/var\(\s*(--[\w-]+)/g)) referenced.add(match[1]) }
|
||||
collect(root.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('appearance-panel', pick(lightTokens))}\n:root[data-theme=dark] ${scope('appearance-panel', pick(darkTokens))}\n`
|
||||
const css = `/* REA ${evidenceId}; appearance-DMmhwsxq.css sha256=${moduleSha}; tokens sha256=${tokensSha}. */\n${root.toString()}\n${scopedTokens}`
|
||||
await fs.writeFile('src/renderer/src/components/source-appearance-panel-recovered.css', css)
|
||||
const rules = []
|
||||
root.walkRules(rule => rules.push(rule.selector))
|
||||
console.log(`Recovered ${rules.length} appearance rules (${css.length} bytes); scoped ${pick(lightTokens).size} light / ${pick(darkTokens).size} dark tokens`)
|
||||
console.log(rules.slice(0, 10).join('\n'))
|
||||
if (rules.length < 20) throw new Error('Unexpectedly few appearance rules')
|
||||
@@ -11,6 +11,9 @@ const windowCss = module.source.artifact.text
|
||||
if (sha(windowCss) !== '8036657ec27f8948c6bad60c835d62469e958db4a5b9c25e22e5a9e60c0ea986') throw new Error('Window CSS changed')
|
||||
const contexts = await fs.readFile('.tmp/rea-css-recovered/contexts-BkjwYSZT.css', 'utf8')
|
||||
if (sha(contexts) !== '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')
|
||||
function project(node) {
|
||||
if (node.type === 'rule') {
|
||||
const selectors = node.selectors.filter(selector => /\.settings-(modal|navigation)/.test(selector))
|
||||
@@ -23,5 +26,42 @@ function project(node) {
|
||||
return null
|
||||
}
|
||||
const root = postcss.root({ nodes: postcss.parse(contexts).nodes.map(project).filter(Boolean) })
|
||||
await fs.writeFile('src/renderer/src/components/settings-shell-source.css', `/* REA ${evidenceId}; settings-window CSS sha256=${sha(windowCss)}; contexts sha256=${sha(contexts)}. */\n${root.toString()}\n${windowCss}\n`)
|
||||
console.log('Recovered settings shell CSS with original media conditions')
|
||||
// 原版把 `--wb-*` 设计变量放在全局 :root(tokens 样式表);YCode 没有全局 token 表,按组件作用域复制。
|
||||
// 设置窗口的根元素是 `.settings-window-root`(原生设置窗口)或 `.settings-modal-overlay`(主窗口内浮层),
|
||||
// 而 `.settings-modal` 自身也直接引用这些变量(背景、圆角、滚动条),因此两处都定义一份,
|
||||
// 深色块让 [data-theme=dark] 下的窗口背景/文字/滚动条随主题切换(TODO 11)。
|
||||
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)
|
||||
}
|
||||
}
|
||||
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(windowCss)
|
||||
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 = (selectors, map) => `${selectors}{${[...map].map(([property, value]) => `${property}:${value}`).join(';')}}`
|
||||
const shellScope = '.settings-window-root,.settings-modal'
|
||||
const darkScope = ':root[data-theme=dark] .settings-window-root,:root[data-theme=dark] .settings-modal'
|
||||
const scopedTokens = `${scope(shellScope, pick(lightTokens))}\n${scope(darkScope, pick(darkTokens))}\n`
|
||||
await fs.writeFile('src/renderer/src/components/settings-shell-source.css', `/* REA ${evidenceId}; settings-window CSS sha256=${sha(windowCss)}; contexts sha256=${sha(contexts)}; tokens sha256=${tokensSha}. */\n${root.toString()}\n${windowCss}\n${scopedTokens}`)
|
||||
console.log(`Recovered settings shell CSS with original media conditions; scoped ${pick(lightTokens).size} light / ${pick(darkTokens).size} dark tokens`)
|
||||
@@ -237,12 +237,12 @@ if (!process.versions.electron) {
|
||||
await wait('!document.querySelector(".ycode-account-menu")', 'menu closed after appearance click')
|
||||
await openSettings('appearance')
|
||||
observe(settings)
|
||||
await wait('!!document.querySelector(".segmented")', 'appearance panel', settings)
|
||||
check('外观 row opens the real appearance panel', await read(`[...document.querySelectorAll('.segmented__item')].map(node=>node.textContent)`, settings), ['浅色', '深色'])
|
||||
check('appearance panel marks the current light theme', await read(`[...document.querySelectorAll('.segmented__item')].map(node=>[node.textContent,node.dataset.active])`, settings), [['浅色', 'true'], ['深色', 'false']])
|
||||
await wait('!!document.querySelector(".appearance-panel")', 'appearance panel', settings)
|
||||
check('外观 row opens the real appearance panel', await read(`[...document.querySelectorAll('.appearance-card')].map(node=>node.getAttribute('aria-label'))`, settings), ['浅色', '深色'])
|
||||
check('appearance panel marks the current light theme', await read(`[...document.querySelectorAll('.appearance-card')].map(node=>[node.getAttribute('aria-label'),String(node.classList.contains('appearance-card--selected'))])`, settings), [['浅色', 'true'], ['深色', 'false']])
|
||||
|
||||
await step('menu-dark-theme')
|
||||
await clickText('.segmented__item', '深色', settings)
|
||||
await mouseClick('[aria-label="深色"].appearance-card', settings)
|
||||
await wait(`document.documentElement.dataset.theme==='dark'`, 'main window receives dark theme')
|
||||
check('appearance row drives the shared theme', await read(`[document.documentElement.dataset.theme,localStorage.getItem('ycode:theme')]`), ['dark', 'dark'])
|
||||
await read(openMenu)
|
||||
@@ -262,12 +262,12 @@ if (!process.versions.electron) {
|
||||
await wait('!document.querySelector(".ycode-account-menu")', 'menu closed after settings click')
|
||||
await wait('!!document.querySelector(".models-settings-panel")', 'models panel in the reused settings window', settings)
|
||||
check('设置 row reuses the native settings window', BrowserWindow.getAllWindows().filter(win => win !== main).length, 1)
|
||||
check('设置 row lands on the models panel', await read(`!!document.querySelector('.models-settings-panel')&&!document.querySelector('.segmented')`, settings), true)
|
||||
check('设置 row lands on the models panel', await read(`!!document.querySelector('.models-settings-panel')&&!document.querySelector('.appearance-panel')`, settings), true)
|
||||
|
||||
await step('menu-light-restore')
|
||||
await read(`document.querySelector('[data-settings-tab="appearance"]').click()`, settings)
|
||||
await wait('!!document.querySelector(".segmented")', 'appearance panel again', settings)
|
||||
await clickText('.segmented__item', '浅色', settings)
|
||||
await wait('!!document.querySelector(".appearance-panel")', 'appearance panel again', settings)
|
||||
await mouseClick('[aria-label="浅色"].appearance-card', settings)
|
||||
await wait(`document.documentElement.dataset.theme==='light'`, 'main window receives light theme')
|
||||
check('light theme is restored through the same settings window', await read(`[document.documentElement.dataset.theme,localStorage.getItem('ycode:theme')]`), ['light', 'light'])
|
||||
const settingsId = settings.id
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
@@ -92,7 +92,7 @@ if (!process.versions.electron) {
|
||||
await step('ui-dark-theme-and-reused-settings-navigation')
|
||||
await openSettings('appearance'); observe(settings)
|
||||
await click('[data-settings-tab="appearance"]', settings)
|
||||
await clickText('.segmented__item', '深色', settings)
|
||||
await click('[aria-label="深色"].appearance-card', settings)
|
||||
await wait('document.documentElement.dataset.theme==="dark"', 'main receives dark appearance')
|
||||
let theme = await themeSnapshot('dark-selected-in-native-settings')
|
||||
check('native UI dark changes both renderers and persistent shared storage', [theme.main, theme.settings], [['dark', 'dark'], ['dark', 'dark']])
|
||||
@@ -100,7 +100,7 @@ if (!process.versions.electron) {
|
||||
const previous = settings.id
|
||||
await openSettings('model'); await wait('!!document.querySelector(".models-settings-panel")', 'reused settings navigates to actual models panel', settings)
|
||||
check('openSettings(model) reuses existing native window', settings.id, previous)
|
||||
check('reused settings actually navigated away from appearance', await read('!!document.querySelector(".models-settings-panel")&&!document.querySelector(".segmented")', settings), true)
|
||||
check('reused settings actually navigated away from appearance', await read('!!document.querySelector(".models-settings-panel")&&!document.querySelector(".appearance-panel")', settings), true)
|
||||
await click('.settings-window-close', settings)
|
||||
for (let i = 0; i < 150 && !settings.isDestroyed(); i++) await pause(20)
|
||||
check('settings close destroys actual native window', settings.isDestroyed(), true)
|
||||
@@ -108,7 +108,7 @@ if (!process.versions.electron) {
|
||||
check('reopened settings uses a fresh native window', settings.id !== previous, true)
|
||||
theme = await themeSnapshot('reopened-settings-persists-dark')
|
||||
check('native settings reopen preserves shared dark appearance', [theme.main, theme.settings], [['dark', 'dark'], ['dark', 'dark']])
|
||||
await clickText('.segmented__item', '浅色', settings)
|
||||
await click('[aria-label="浅色"].appearance-card', settings)
|
||||
await wait('document.documentElement.dataset.theme==="light"', 'main receives light appearance')
|
||||
theme = await themeSnapshot('light-restored-in-native-settings')
|
||||
check('native UI light restores both renderers and storage', [theme.main, theme.settings], [['light', 'light'], ['light', 'light']])
|
||||
|
||||
@@ -144,8 +144,8 @@ if (!process.versions.electron) {
|
||||
|
||||
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('.segmented')`, 'appearance panel', settings)
|
||||
check('appearance entry still opens its panel', await read(`!!document.querySelector('.segmented')&&!document.querySelector('.models-settings-panel')`, settings), true)
|
||||
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)
|
||||
|
||||
Reference in new issue
Block a user