Files
ycode/scripts/verify-chat-header-parity.cjs

127 lines
12 KiB
JavaScript

// Test only the new YCode component. Original geometry comes exclusively from fixed REA exports.
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict')
const { app, BrowserWindow } = require('electron')
const calibrated = process.argv.includes('--visible-swiftshader')
app.disableHardwareAcceleration()
if (calibrated) { app.commandLine.appendSwitch('use-angle', 'swiftshader'); app.commandLine.appendSwitch('disable-gpu-compositing') }
const root = path.resolve(__dirname, '..'), output = path.join(root, calibrated ? '.tmp/chat-header-native-swiftshader-20261016' : '.tmp/chat-header-native-20261016')
assert.ok(output.startsWith(path.join(root, '.tmp') + path.sep))
fs.mkdirSync(output, { recursive: true })
app.setPath('userData', path.join(output, 'user-data'))
const sourceClosed = JSON.parse(fs.readFileSync(path.join(root, '.tmp/rea-chat-header-final-20261016/layout.json'), 'utf8'))
const sourceOpen = JSON.parse(fs.readFileSync(path.join(root, '.tmp/rea-chat-header-static-20261016/layout.json'), 'utf8'))
const sourceSearch = JSON.parse(fs.readFileSync(path.join(root, '.tmp/rea-chat-header-title-20261016/layout.json'), 'utf8'))
const getSource = (evidence, selector) => evidence.layout.documents[0].nodes.filter(node => node.attributes.class?.split(' ').includes(selector.slice(1)))
const widthClosed = getSource(sourceClosed, '.workbuddy-topbar')[0].bounds[2]
const widthOpen = getSource(sourceOpen, '.workbuddy-topbar')[0].bounds[2]
const fixture = path.join(output, 'fixture.tsx')
const component = path.join(root, 'src/renderer/src/components/SourceChatHeader').replaceAll('\\', '/')
fs.writeFileSync(fixture, `import React,{useState} from 'react';import{createRoot}from'react-dom/client';import Header from ${JSON.stringify(component)};
import ${JSON.stringify(path.join(root, 'src/renderer/src/styles/app.css').replaceAll('\\', '/'))};
function Fixture(){const[title,setTitle]=useState('开始新会话'),[right,setRight]=useState(false),[open,setOpen]=useState(false),[keyword,setKeyword]=useState(''),[index,setIndex]=useState(0);const count=keyword?3:0;window.__headerQA??={renames:[],shares:0,toggles:0};return <div style={{position:'absolute',left:284.28125,top:30,width:right?${widthOpen}:${widthClosed}}}><Header title={title} rightVisible={right} onToggleRight={()=>{window.__headerQA.toggles++;setRight(v=>!v)}} onTitleChange={async value=>{window.__headerQA.renames.push(value);setTitle(value)}} onShare={()=>{window.__headerQA.shares++}} search={{open,keyword,hitCount:count,activeIndex:index,onOpen:()=>setOpen(true),onClose:()=>{setOpen(false);setKeyword('');setIndex(0)},onKeywordChange:value=>{setKeyword(value);setIndex(0)},onNext:()=>setIndex(v=>(v+1)%count),onPrev:()=>setIndex(v=>(v+count-1)%count)}}/></div>}createRoot(document.getElementById('root')).render(<Fixture/>);`)
const selectors = ['.workbuddy-topbar', '.workbuddy-topbar-title-area', '.workbuddy-topbar-title',
'.workbuddy-topbar-actions', '.workbuddy-topbar-actions > .wb-button', '.cb-chat-search',
'.cb-chat-search__input', '.cb-chat-search__nav', '.cb-chat-search__close', '.workbuddy-topbar-title-input']
const properties = ['font-family', 'font-size', 'font-weight', 'line-height', 'color', 'background-color',
'padding-left', 'padding-right', 'gap', 'border-radius', 'display']
app.whenReady().then(async () => {
await require('esbuild').build({ entryPoints: [fixture], bundle: true, outfile: path.join(output, 'fixture.js'),
jsx: 'automatic', platform: 'browser', format: 'iife', logLevel: 'warning' })
fs.writeFileSync(path.join(output, 'index.html'), '<html data-theme="light"><head><meta charset="utf-8"><link rel="stylesheet" href="fixture.css"><style>html,body{margin:0;width:100vw;height:100vh;background:white}</style></head><body><div id="root"></div><script src="fixture.js"></script></body></html>')
const win = new BrowserWindow({ width: 1336, height: 901, useContentSize: true, frame: false, show: false,
webPreferences: { contextIsolation: true, nodeIntegration: false, backgroundThrottling: false, offscreen: !calibrated } })
const errors = []
win.webContents.on('console-message', (_e, level, message) => { if (level === 3) errors.push(message) })
await win.loadFile(path.join(output, 'index.html'))
win.webContents.debugger.attach('1.3')
await win.webContents.debugger.sendCommand('Page.enable')
if (calibrated) win.showInactive()
const capture = async () => calibrated
? Buffer.from((await win.webContents.debugger.sendCommand('Page.captureScreenshot', { format: 'png', fromSurface: true, captureBeyondViewport: false })).data, 'base64')
: (await win.webContents.capturePage()).toPNG()
const settle = async () => { await capture(); await new Promise(resolve => setTimeout(resolve, 160)) }
const evaluate = code => win.webContents.executeJavaScript(code)
const point = selector => evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e)throw Error('Missing '+${JSON.stringify(selector)});const r=e.getBoundingClientRect();return{x:r.x+r.width/2,y:r.y+r.height/2}})()`)
const hover = async selector => win.webContents.debugger.sendCommand('Input.dispatchMouseEvent', { type: 'mouseMoved', ...await point(selector) })
const click = async selector => { const p = await point(selector); for (const type of ['mousePressed', 'mouseReleased']) await win.webContents.debugger.sendCommand('Input.dispatchMouseEvent', { type, ...p, button: 'left', clickCount: 1 }); await settle() }
const key = async (key, code = key, modifiers = 0) => { for (const type of ['keyDown', 'keyUp']) await win.webContents.debugger.sendCommand('Input.dispatchKeyEvent', { type, key, code, modifiers, windowsVirtualKeyCode: key === 'Enter' ? 13 : key === 'Escape' ? 27 : 65 }) }
const measure = () => evaluate(`(${function (selectors, properties) {
return selectors.flatMap(selector => Array.from(document.querySelectorAll(selector), (el, index) => {
const r = el.getBoundingClientRect(), s = getComputedStyle(el)
return { selector, index, text: el.textContent, bounds: [r.x, r.y, r.width, r.height],
styles: Object.fromEntries(properties.map(key => [key, s.getPropertyValue(key)])) }
}))
}.toString()})(${JSON.stringify(selectors)},${JSON.stringify(properties)})`)
await settle()
const closed = await measure()
fs.writeFileSync(path.join(output, 'collapsed.png'), await capture())
await click('button[aria-label="对话内搜索"]')
// The real REA search snapshot was taken after its probe restored a neutral pointer.
await win.webContents.debugger.sendCommand('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 0, y: 0 })
await settle()
const searching = await measure()
fs.writeFileSync(path.join(output, 'search.png'), await capture())
await win.webContents.debugger.sendCommand('Input.insertText', { text: '测试' })
await settle()
assert.equal(await evaluate('document.querySelector(".cb-chat-search__counter").textContent'), '1/3')
await key('Enter'); await settle()
assert.equal(await evaluate('document.querySelector(".cb-chat-search__counter").textContent'), '2/3')
await key('Enter', 'Enter', 8); await settle()
assert.equal(await evaluate('document.querySelector(".cb-chat-search__counter").textContent'), '1/3')
await key('Escape'); await settle()
assert.equal(await evaluate('document.querySelector(".cb-chat-search")'), null)
await hover('.workbuddy-topbar-title-area'); await settle()
await click('.workbuddy-topbar-edit-button')
const editing = await measure()
fs.writeFileSync(path.join(output, 'edit.png'), await capture())
const sourceEdit = sourceSearch.probe.frames.find(frame => frame.step_id === 'title-editor-open').regions.find(region => region.selector === '.workbuddy-topbar-title-input')
assert.deepEqual(editing.find(node => node.selector === '.workbuddy-topbar-title-input').bounds, sourceEdit.bounds)
await win.webContents.debugger.sendCommand('Input.insertText', { text: '取消修改' })
await key('Escape'); await settle()
assert.deepEqual(await evaluate('window.__headerQA.renames'), [])
await hover('.workbuddy-topbar-title-area'); await settle()
await click('.workbuddy-topbar-edit-button')
await win.webContents.debugger.sendCommand('Input.insertText', { text: '原生编辑已保存' })
await key('Enter'); await settle()
assert.deepEqual(await evaluate('window.__headerQA.renames'), ['原生编辑已保存'])
await click('button[aria-label="分享任务"]')
assert.equal(await evaluate('window.__headerQA.shares'), 1)
await click('button[aria-label="打开右侧栏"]')
const opened = await measure()
assert.equal(await evaluate('document.querySelector("button[aria-label=打开右侧栏]")'), null)
assert.equal(await evaluate('window.__headerQA.toggles'), 1)
function compare(samples, evidence, cases) {
for (const [selector, sourceSelector, fields] of cases) {
const ownNodes = samples.filter(n => n.selector === selector), original = getSource(evidence, sourceSelector)
assert.equal(ownNodes.length, original.length, selector)
ownNodes.forEach((own, index) => {
assert.deepEqual(own.bounds, original[index].bounds, selector + ' bounds')
for (const property of fields) assert.equal(own.styles[property], original[index].styles[property], selector + ' ' + property)
})
}
}
const geometryCases = [['.workbuddy-topbar', '.workbuddy-topbar', ['font-family', 'font-size', 'font-weight', 'line-height', 'color', 'padding-left', 'padding-right', 'gap']],
['.workbuddy-topbar-actions', '.workbuddy-topbar-actions', ['gap']]]
compare(closed, sourceClosed, geometryCases)
// Original title selectors include the empty workspace slot, so compare the actual title separately.
const ownTitle = closed.find(n => n.selector === '.workbuddy-topbar-title' && n.text === '开始新会话')
const originalTitle = getSource(sourceClosed, '.workbuddy-topbar-title').find(n => n.node_name === 'SPAN')
assert.deepEqual(ownTitle.bounds, originalTitle.bounds)
for (const property of ['font-family', 'font-size', 'font-weight', 'line-height', 'color']) assert.equal(ownTitle.styles[property], originalTitle.styles[property])
compare(searching, sourceSearch, [['.cb-chat-search', '.cb-chat-search', ['background-color', 'padding-left', 'padding-right', 'border-radius']],
['.cb-chat-search__input', '.cb-chat-search__input', ['font-family', 'font-size', 'line-height']],
['.cb-chat-search__nav', '.cb-chat-search__nav', []], ['.cb-chat-search__close', '.cb-chat-search__close', []]])
compare(opened, sourceOpen, geometryCases)
assert.deepEqual(errors, [])
const report = { status: 'PASS', evidenceIds: [sourceClosed.evidenceId, sourceOpen.evidenceId, sourceSearch.evidenceId],
closed, searching, editing, opened, errors, actions: await evaluate('window.__headerQA'),
rendering: { mode: calibrated ? 'visible-swiftshader' : 'offscreen-disabled-gpu', visible: win.isVisible(),
captureMethod: calibrated ? 'Page.captureScreenshot(fromSurface:true,captureBeyondViewport:false)' : 'webContents.capturePage',
...(calibrated ? { gpu: await app.getGPUInfo('complete'), gpuFeatures: app.getGPUFeatureStatus() } : {}) },
limitations: ['Isolated component fixture with controlled host callbacks. Production search/rename/share integration is independently owned by the parent task.',
'Only the observed Windows 1336x901 stable states are compared. Tooltips, compact/Mac/dark, prompt-list runtime and full glyph pixel parity are not claimed.'] }
fs.writeFileSync(path.join(output, 'report.json'), JSON.stringify(report, null, 2))
console.log(JSON.stringify({ status: 'PASS', output, errors, evidenceIds: report.evidenceIds, actions: report.actions }))
win.webContents.debugger.detach(); win.destroy(); app.quit()
}).catch(error => { console.error(error); app.exit(1) })