feat: complete WorkBuddy parity and Windows release workflow

This commit is contained in:
xiaohan.yuan committed 2026-10-18 12:45:21 +08:00
1 parent 143516b3a8
commit d5512df549
434 files changed
+54977 -1854

No files matched your search

+244
View File
@@ -0,0 +1,244 @@
// Capture stable module states in the production app. WorkBuddy references are
// read only from retained REA Evidence; this harness controls YCode exclusively.
const fs = require('node:fs/promises')
const path = require('node:path')
const assert = require('node:assert/strict')
if (!process.versions.electron) {
;(async () => {
await fs.mkdir(path.resolve('.tmp'), { recursive: true })
const directory = await fs.mkdtemp(path.resolve('.tmp/module-ui-parity-'))
await Promise.all(['data', 'home', 'workspace'].map(name => fs.mkdir(path.join(directory, name))))
const data = path.join(directory, 'data'), cwd = path.join(directory, 'workspace'), now = Date.now()
await fs.writeFile(path.join(cwd, 'report.md'), '# UI parity artifact\n')
await fs.writeFile(path.join(data, 'models.json'), JSON.stringify({ version: 1, selectedModelId: 'parity-model', models: [
{ id: 'parity-model', name: 'DeepSeek V4', providerId: 'deepseek', model: 'deepseek-v4', baseUrl: 'http://127.0.0.1:1/v1', authMode: 'none' }
] }))
await fs.writeFile(path.join(data, 'workspaces.json'), JSON.stringify({ selectedWorkspaceId: 'parity-workspace', workspaces: [
{ id: 'parity-workspace', name: 'YCode', root: cwd, createdAt: now }
] }))
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions: [{ id: 'parity-artifact', title: '生成项目报告',
cwd, workspaceId: 'parity-workspace', modelId: 'parity-model', createdAt: now, updatedAt: now,
messages: [{ id: 'artifact-user', role: 'user', content: '生成报告', createdAt: now - 1, status: 'done' },
{ id: 'artifact-assistant', role: 'assistant', content: '报告已生成。', createdAt: now, status: 'done', parts: [], tools: [
{ id: 'artifact-write', name: 'write_file', args: { path: 'report.md', content: '# UI parity artifact\n' }, argsRaw: '{}', status: 'ok' }
] }]
}] }))
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: path.join(directory, 'home'),
YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '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 log = ''
child.stdout.on('data', bytes => { log += bytes; process.stdout.write(bytes) })
child.stderr.on('data', bytes => { log += bytes })
const timer = setTimeout(() => child.kill(), 110_000)
child.once('exit', async code => {
clearTimeout(timer)
await fs.writeFile(path.join(directory, 'process.log'), log)
console.log(JSON.stringify({ directory, exitCode: code }))
if (code) console.error(log.slice(-10000))
process.exitCode = code ?? 1
})
})().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 relative = path.relative(path.resolve('.tmp'), directory || '')
assert.ok(relative.startsWith('module-ui-parity-') && !relative.includes(path.sep))
app.setPath('home', path.join(directory, 'home'))
let win, phase = 'boot'
const screenshots = [], geometry = {}, errors = [], checks = []
const read = source => win.webContents.executeJavaScript(source)
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
const wait = async source => {
for (let count = 0; count < 400; count++) { if (await read(source)) return; await pause(30) }
throw Error(`Timeout ${phase}: ${source}`)
}
const rects = async selectors => read(`Object.fromEntries(${JSON.stringify(selectors)}.map(selector => {
const el = document.querySelector(selector); if (!el) return [selector,null];
const r=el.getBoundingClientRect(),s=getComputedStyle(el);
return [selector,{x:r.x,y:r.y,width:r.width,height:r.height,font:s.fontFamily,size:s.fontSize,weight:s.fontWeight,
lineHeight:s.lineHeight,background:s.backgroundColor,color:s.color,radius:s.borderRadius,gap:s.gap,padding:s.padding,
justify:s.justifyContent,borderTopWidth:s.borderTopWidth,disabledColor:s.getPropertyValue('--wb-button-plain-fg').trim()}]
}))`)
const capture = async (name, selectors = []) => {
phase = name
await pause(700)
geometry[name] = await rects(selectors)
const filename = `${name}.png`
await fs.writeFile(path.join(directory, filename), (await win.webContents.capturePage()).toPNG())
screenshots.push(filename)
}
const navigate = async name => {
await read(`Array.from(document.querySelectorAll('.conversation-list-tab-button')).find(el=>el.textContent.trim()===${JSON.stringify(name)}).click()`)
}
const setTheme = async theme => {
await read(`localStorage.setItem('ycode:theme',${JSON.stringify(theme)});window.dispatchEvent(new StorageEvent('storage',{key:'ycode:theme',newValue:${JSON.stringify(theme)}}))`)
await wait(`document.documentElement.dataset.theme===${JSON.stringify(theme)}`)
}
const baseline = process.env.YCODE_UI_PARITY_BASELINE === '1'
const check = (label, condition) => {
checks.push({ label, pass: Boolean(condition) })
if (!baseline) assert.ok(condition, label)
}
const closeEditor = async () => {
await read(`document.querySelector('.automation-editor-modal button[aria-label="关闭"]').click()`)
await wait(`!document.querySelector('.automation-editor-modal')`)
}
;(async () => {
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
await app.whenReady()
for (let count = 0; count < 350; count++) {
win = BrowserWindow.getAllWindows().find(item => item.getTitle() === 'YCode')
if (win && !win.webContents.isLoading() && await read('!!document.querySelector(".sidebar") && !!window.ycode?.automation')) break
await pause(30)
}
assert.ok(win)
win.setSize(1336, 902)
win.webContents.on('console-message', (...args) => {
const event = args[0], level = typeof event === 'object' ? event.level : args[1]
if (level === 'error' || Number(level) >= 3) errors.push(typeof event === 'object' ? event.message : args[2])
})
for (const theme of ['light', 'dark']) {
await setTheme(theme)
await navigate('定时任务')
await wait(`document.querySelector('.automation-page') && document.body.innerText.includes('暂无定时任务')`)
await capture(`automation-empty-${theme}`, ['.sidebar','.automation-page','.automation-toolbar','.automation-empty-state','.automation-template-card'])
await read('document.querySelector(".automation-create-main").click()')
await wait(`!!document.querySelector('.automation-editor-modal')`)
await capture(`automation-editor-${theme}`, ['.automation-editor-modal','.automation-name-input','.automation-prompt-composer','.automation-prompt-input','.atm-schedule-v2','.automation-editor-footer'])
const editor = geometry[`automation-editor-${theme}`]
// The original layout archive caught opacity:0 / scale(.96). Undo that
// entrance frame instead of teaching the app to stay at its shrunken size.
check(`${theme}: source stable editor width 800`, Math.abs(editor['.automation-editor-modal'].width - 800) < 1)
check(`${theme}: source stable editor height 490`, Math.abs(editor['.automation-editor-modal'].height - 490) < 2)
check(`${theme}: source name input height 40`, Math.abs(editor['.automation-name-input'].height - 40) < 1)
check(`${theme}: source name input width 752`, Math.abs(editor['.automation-name-input'].width - 752) < 1)
check(`${theme}: source editor typography`, editor['.automation-editor-modal'].font === '"PingFang SC", -apple-system, BlinkMacSystemFont, sans-serif')
await read(`document.querySelector('[aria-label="重复频率"]').click()`)
await wait(`document.querySelectorAll('.atm-freq-menu-popover [role="option"]').length === 7`)
await capture(`automation-frequency-${theme}`, ['.atm-freq-menu-popover'])
check(`${theme}: frequency uses source popover`, !await read(`!!document.querySelector('select[aria-label="重复频率"]')`))
await read(`document.querySelector('.atm-freq-menu-popover [role="option"][aria-selected="true"]').click()`)
await read(`document.querySelector('.automation-name-input').focus()`)
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' })
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' })
await pause(100)
check(`${theme}: Escape keeps original automation editor open`, await read(`!!document.querySelector('.automation-editor-modal')`))
if (await read(`!!document.querySelector('.automation-editor-modal')`)) {
await read(`document.querySelector('.automation-editor-overlay').dispatchEvent(new MouseEvent('mousedown',{bubbles:true}))`)
check(`${theme}: overlay keeps original automation editor open`, await read(`!!document.querySelector('.automation-editor-modal')`))
if (await read(`!!document.querySelector('.automation-editor-modal')`)) await closeEditor()
}
await navigate('资料库')
await wait(`!!document.querySelector('.library-page')`)
await capture(`library-recent-${theme}`, ['.library-page','.library-sidebar','.library-content','.knowledge-sidebar-pc'])
await read(`Array.from(document.querySelectorAll('.library-nav-item')).find(el=>el.textContent.includes('本地产物')).click()`)
await wait(`!!document.querySelector('.library-file-row')`)
await capture(`library-local-${theme}`, ['.library-page','.library-sidebar','.library-content','.library-local-table-header','.library-local-scroll','.library-file-row','.library-file-button'])
check(`${theme}: local artifacts use full main panel`, !await read(`!!document.querySelector('.library-sidebar')`))
check(`${theme}: source updater column restored`, await read(`Array.from(document.querySelectorAll('.library-page th, .library-local-table-header .my-files-flat-col-author')).some(el=>el.textContent.includes('更新人'))`))
check(`${theme}: local artifacts preserve actual source file`, await read(`document.querySelector('.library-file-button')?.textContent.includes('report.md')`))
const local = geometry[`library-local-${theme}`]
check(`${theme}: local table spans main area`, Math.abs(local['.library-local-table-header'].width - 1004) < 1)
check(`${theme}: source local table header position`, Math.abs(local['.library-local-table-header'].y - 150) < 1)
check(`${theme}: source local table scroll reaches window bottom`, Math.abs(local['.library-local-scroll'].y + local['.library-local-scroll'].height - 902) < 1)
await read('document.querySelector(".conversation-list-tab-button-more").click()')
await wait('!!document.querySelector(".yc-project-more button")')
await read('document.querySelector(".yc-project-more button").click()')
await wait('!!document.querySelector(".landing-header")')
await capture(`project-list-${theme}`, ['.landing-header','.landing-template-card','.workbuddy-topbar'])
await read('document.querySelector(".landing-new-btn").click()')
await wait('!!document.querySelector(".collab-modal__container")')
await capture(`project-create-${theme}`, ['.collab-modal__container','#yc-project-name','#yc-project-instructions'])
await read(`Array.from(document.querySelectorAll('.collab-modal__container button')).find(el=>el.textContent.trim()==='取消').click()`)
await wait('!document.querySelector(".collab-modal__container")')
}
const project = await read(`window.ycode.projects.create({name:'项目UI验收',instructions:'项目约束内容',experts:[],skills:[],connectors:[]})`)
await navigate('资料库')
await read('document.querySelector(".conversation-list-tab-button-more").click()')
await wait('!!document.querySelector(".yc-project-more button")')
await read('document.querySelector(".yc-project-more button").click()')
await wait('!!document.querySelector(".project-grid__card[role=button]")')
await read('document.querySelector(".project-grid__card[role=button]").click()')
await wait('!!document.querySelector(".project-detail-view")')
for (const theme of ['dark','light']) {
await setTheme(theme)
await capture(`project-detail-${theme}`, [
'.project-detail-view > .workbuddy-topbar',
'.project-detail-view__main',
'.project-detail-view__panel',
'.project-detail-view__project-composer',
'.project-composer__controls .cr-input-container',
'.project-composer__controls .cr-input-box__main',
'.project-composer__controls .wb-composer__workspace-footer',
'.project-composer__controls .wb-composer__workspace-slot',
'.project-composer__controls .cr-permission-setting',
'.project-composer__controls .cr-send-button',
'.project-detail-view__members-btn',
'.project-detail-view__breadcrumb-domain-icon'
])
const detail = geometry[`project-detail-${theme}`]
check(`${theme}: source project topbar height 64`, Math.abs(detail['.project-detail-view > .workbuddy-topbar'].height - 64) < 1)
for (const selector of ['.project-detail-view__main','.project-detail-view__panel']) {
check(`${theme}: ${selector} fills available height`, Math.abs(detail[selector].y + detail[selector].height - 902) < 1)
check(`${theme}: ${selector} source typography`, detail[selector].font === '"PingFang SC", -apple-system, BlinkMacSystemFont, sans-serif')
}
check(`${theme}: project composer stays at bottom`, Math.abs(detail['.project-detail-view__project-composer'].y + detail['.project-detail-view__project-composer'].height - 878) < 1)
const workspace = detail['.project-composer__controls .wb-composer__workspace-slot']
const permission = detail['.project-composer__controls .cr-permission-setting']
const footer = detail['.project-composer__controls .wb-composer__workspace-footer']
const mainArea = detail['.project-composer__controls .cr-input-box__main']
const send = detail['.project-composer__controls .cr-send-button']
check(`${theme}: project permission follows workspace`, Math.abs(permission.x - workspace.x - workspace.width) <= 8)
check(`${theme}: project footer is left aligned with source gap`, footer.justify === 'flex-start' && footer.gap === '4px' && footer.padding === '0px 12px')
check(`${theme}: empty project send button disabled`, await read(`document.querySelector('.project-composer__controls .cr-send-button').disabled`))
check(`${theme}: project source folder icon`, await read(`document.querySelector('.project-detail-view__breadcrumb-domain-icon path')?.getAttribute('d')?.startsWith('M5.1578')`))
if (theme === 'dark') {
check('dark: project input source background', detail['.project-composer__controls .cr-input-container'].background === 'rgb(31, 31, 31)')
check('dark: project main area source background', mainArea.background === 'rgb(31, 31, 31)')
check('dark: project input card source radius and border', detail['.project-composer__controls .cr-input-container'].radius === '16px' && detail['.project-composer__controls .cr-input-container'].borderTopWidth === '1px')
check('dark: disabled invite keeps source primary appearance', detail['.project-detail-view__members-btn'].background === 'rgba(255, 255, 255, 0.9)' && detail['.project-detail-view__members-btn'].color === 'rgba(0, 0, 0, 0.9)')
check('dark: disabled send uses source foreground mix', send.disabledColor.includes('color-mix') && send.disabledColor.includes('40%'))
}
check(`${theme}: project detail uses the WorkBuddy font stack`, await read(`[
'.project-detail-view > .workbuddy-topbar',
'.project-detail-view__main',
'.project-detail-view__panel',
'.project-detail-view__project-composer'
].every(selector => {
const family=getComputedStyle(document.querySelector(selector)).fontFamily
return family.includes('PingFang SC') && !/(Segoe UI|Roboto|Helvetica Neue)/.test(family)
})`))
}
win.setSize(900, 700)
await capture('project-detail-narrow', ['.project-detail-view__main','.project-detail-view__panel','.project-detail-view__project-composer'])
const narrow = geometry['project-detail-narrow']['.project-detail-view__main']
check('narrow: project resizes with viewport height', Math.abs(narrow.y + narrow.height - 700) < 1)
check('module renderer has no console errors', errors.length === 0)
const sources = [
['automationEditor','.tmp/rea-todo20-scheduled-create.json'],
['automationPage','.tmp/rea-todo20-scheduled-page.json'],
['libraryRecent','.tmp/rea-four-modules-library-stable-20261017.json'],
['libraryLocal','.tmp/rea-four-modules-library-local-20261017.json'],
['projectList','.tmp/rea-todo20-project-page.json'],
['projectDetail','.tmp/rea-todo20-project-detail.json']
]
const references = {}
for (const [name, filename] of sources) {
const e = JSON.parse((await fs.readFile(filename,'utf8')).replace(/^\uFEFF/,''))
references[name] = { evidenceId:e.evidence_id, archive:path.resolve(filename), viewport:e.normalized_result.visuals.viewport }
}
await fs.writeFile(path.join(directory,'report.json'),JSON.stringify({ result:baseline?'BASELINE':'PASS',checks,geometry,screenshots,references,project,rendererErrors:errors,
limitations:['Stable-state geometry and screenshots; not a full-state zero-pixel-difference claim.','Original and candidate data differ; no cloud example data is copied.'] },null,2))
console.log(`MODULE_UI_PARITY_${baseline ? 'BASELINE' : 'PASS'} ${directory}`)
app.exit(0)
})().catch(async error => {
console.error(error)
if(win&&!win.isDestroyed()) await fs.writeFile(path.join(directory,'failure.png'),(await win.webContents.capturePage()).toPNG()).catch(()=>{})
await fs.writeFile(path.join(directory,'failure.json'),JSON.stringify({phase,error:String(error.stack||error),checks,geometry,rendererErrors:errors},null,2))
app.exit(1)
})
}