Files
ycode/scripts/verify-sidebar-footer.cjs
T

206 lines
20 KiB
JavaScript
Raw 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.
// 真实隔离 YCode App + 真实 preload/IPC/渲染进程,验证侧栏底部账号区(TODO 07)。
// 原版期望值:
// - 样式表 contexts-BkjwYSZT.css:.conversation-list-footer{border-top:.5px solid var(--wb-color-border-primary);flex-shrink:0;
// justify-content:flex-start;align-items:center;padding:8px 12px 8px 8px;display:flex} / .conversation-list-footer .user-menu{width:100%}
// - 样式表 UserMenu-CpHz-0rF.css:.user-menu-trigger-wrapper{gap:4px;width:100%;display:flex;position:relative}、
// .user-menu-trigger--workbuddy{box-sizing:border-box;border-radius:8px;flex:1;min-width:0;min-height:40px;padding:6px 8px;overflow:hidden}、
// .user-menu-trigger-left{gap:10px}、头像 28×28 圆形、.user-menu-trigger-name{font-size:12px;font-weight:600;line-height:12px}、
// .msg-center-bell-btn / .user-menu-trigger-miniprogram{32×32;border-radius:6px;transition:background-color .15s,color .15s}、
// .msg-center-badge{min-width:14px;height:14px;border-radius:7px;font-size:9px;line-height:14px;top:2px;right:2px;transform:translate(40%,-30%)}
// - 运行时布局证据 ev_721d830c97114930e046ec8931bd2f473a11ab23efcab9c727c4256889e29879:
// .conversation-list-footer [0,845,283.28,57]、padding 8/12/8/8、border-top 1px rgb(230,230,230);
// .user-menu-trigger-wrapper [8,854,263.28,40];触发器 191.28×40;铃铛/小程序各 32×32;头像 28×28;姓名 12/600/12;
// 同一证据里顶部 .conversation-list-topbar-actions 才承载收起/筛选入口,底部没有收起按钮。
const fs = require('node:fs/promises')
const path = require('node:path')
const assert = require('node:assert/strict')
if (!process.versions.electron) {
;(async () => {
const directory = await fs.mkdtemp(path.resolve('.tmp/sidebar-footer-'))
const data = path.join(directory, 'data')
const home = path.join(directory, 'home')
const cwd = path.join(directory, 'fixture-workspace')
await fs.mkdir(data); await fs.mkdir(home); await fs.mkdir(cwd)
const now = Date.now()
const sessions = [{ id: 'footer-live', title: 'Footer Live', cwd, createdAt: now, updatedAt: now + 1, messages: [
{ id: 'footer-user', role: 'user', content: '底部账号行验收', createdAt: now, status: 'done', requestId: 'footer-run' },
{ id: 'footer-assistant', role: 'assistant', content: '收到。', createdAt: now + 1, status: 'done', requestId: 'footer-run', tools: [], parts: [] }
] }]
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions }))
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data,
YCODE_OMP_HOME: home, YCODE_DISABLE_GPU: '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('SIDEBAR_FOOTER_')) process.stdout.write(bytes) })
child.stderr.on('data', bytes => { output += bytes })
const timer = setTimeout(() => child.kill(), 180000)
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)
process.exitCode = code ? 1 : 0
})
})()
} else {
const { app, BrowserWindow } = require('electron')
app.disableHardwareAcceleration()
const directory = process.env.YCODE_QA_DIRECTORY
assert.ok(path.relative(path.resolve('.tmp'), directory).startsWith('sidebar-footer-'))
app.setPath('userData', path.join(directory, 'data')); app.setPath('sessionData', path.join(directory, 'data'))
app.setPath('home', path.join(directory, 'home'))
let main, phase = 'boot'
const checks = [], snapshots = {}, rendererErrors = []
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
async function read(source) {
let timer
try {
return await Promise.race([main.webContents.executeJavaScript(source), new Promise((_, reject) => {
timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 100))), 15000)
})])
} catch (error) { throw Error(error.message + ' - ' + source.slice(0, 240)) } finally { clearTimeout(timer) }
}
async function wait(source, label) {
for (let index = 0; index < 250; index++) { const value = await read(source); if (value) return value; await pause(30) }
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 footerSource = `(()=>{const footer=document.querySelector('.conversation-list-footer');if(!footer)return null;
const style=getComputedStyle(footer);const box=footer.getBoundingClientRect();
const wrapper=footer.querySelector('.user-menu-trigger-wrapper');const wstyle=getComputedStyle(wrapper);const wbox=wrapper.getBoundingClientRect();
const trigger=footer.querySelector('.user-menu-trigger--workbuddy');const tstyle=getComputedStyle(trigger);const tbox=trigger.getBoundingClientRect();
const left=footer.querySelector('.user-menu-trigger-left');const avatar=footer.querySelector('.user-menu-trigger-avatar-img');
const name=footer.querySelector('.user-menu-trigger-name');const nstyle=getComputedStyle(name);
const bell=footer.querySelector('.msg-center-bell-btn');const bstyle=getComputedStyle(bell);const bbox=bell.getBoundingClientRect();
const mini=footer.querySelector('.user-menu-trigger-miniprogram');const mstyle=getComputedStyle(mini);const mbox=mini.getBoundingClientRect();
const badge=footer.querySelector('.msg-center-badge');const gstyle=badge?getComputedStyle(badge):null;const gbox=badge?badge.getBoundingClientRect():null;
const abox=avatar.getBoundingClientRect();const abstyle=getComputedStyle(avatar);
const box4=r=>[r.x,r.y,r.width,r.height];
return {footer:{box:box4(box),padding:[style.paddingTop,style.paddingRight,style.paddingBottom,style.paddingLeft],borderTop:style.borderTopWidth,borderColor:style.borderTopColor,display:style.display,alignItems:style.alignItems,justifyContent:style.justifyContent},
wrapper:{box:box4(wbox),gap:wstyle.gap,width:wstyle.width,display:wstyle.display,position:wstyle.position,
children:[...wrapper.children].map(node=>node.className)},
trigger:{box:box4(tbox),minHeight:tstyle.minHeight,padding:tstyle.padding,borderRadius:tstyle.borderRadius,transition:tstyle.transitionProperty+' '+tstyle.transitionDuration,flexGrow:tstyle.flexGrow,flexShrink:tstyle.flexShrink},
left:{gap:left?getComputedStyle(left).gap:null,display:left?getComputedStyle(left).display:null},
avatar:{box:box4(abox),radius:abstyle.borderRadius,background:abstyle.backgroundColor,font:abstyle.fontSize+'/'+abstyle.lineHeight,color:abstyle.color},
name:{font:nstyle.fontSize+'/'+nstyle.lineHeight,weight:nstyle.fontWeight,color:nstyle.color,overflow:nstyle.overflow,textOverflow:nstyle.textOverflow,whiteSpace:nstyle.whiteSpace},
bell:{box:box4(bbox),radius:bstyle.borderRadius,color:bstyle.color,transition:bstyle.transitionProperty+' '+bstyle.transitionDuration},
mini:{box:box4(mbox),radius:mstyle.borderRadius,color:mstyle.color},
badge:badge?{box:box4(gbox),minWidth:gstyle.minWidth,height:gstyle.height,radius:gstyle.borderRadius,font:gstyle.fontSize+'/'+gstyle.lineHeight,weight:gstyle.fontWeight,background:gstyle.backgroundColor,color:gstyle.color,top:gstyle.top,right:gstyle.right,transform:gstyle.transform}:null,
collapseInFooter:[...footer.querySelectorAll('[title]')].map(node=>node.getAttribute('title')).filter(title=>/收起/.test(title))}})()`
;(async () => {
await import('node:url').then(({ pathToFileURL }) => import(pathToFileURL(path.resolve('out/main/index.mjs')).href))
await app.whenReady()
main = BrowserWindow.getAllWindows()[0]
main.setSize(1336, 902)
main.showInactive()
main.webContents.debugger.attach('1.3')
main.webContents.on('console-message', (_event, level, message) => { if (level >= 2) rendererErrors.push(message) })
await wait('!!document.querySelector("#root")', 'renderer root')
await pause(1500)
await step('footer-layout')
await read(`(()=>{const rows=[...document.querySelectorAll('[data-session-id]')];const row=rows.find(item=>item.textContent.includes('Footer Live'));if(!row)return false;row.click();return true})()`)
await wait(`!!document.querySelector('.conversation-list-footer')`, 'sidebar footer')
await pause(400)
const footer = await read(footerSource)
snapshots.footer = footer
check('footer keeps the recovered padding contract', footer.footer.padding.join(','), '8px,12px,8px,8px')
check('footer keeps the recovered flex contract', footer.footer.display === 'flex' && footer.footer.alignItems === 'center' && footer.footer.justifyContent === 'flex-start', true)
check('footer keeps the recovered top border', footer.footer.borderTop === '1px' && footer.footer.borderColor === 'rgb(230, 230, 230)', true)
check('footer height stays 57px like the original', Math.round(footer.footer.box[3]), 57)
check('wrapper matches the recovered gap and width', footer.wrapper.gap === '4px' && footer.wrapper.display === 'flex' && footer.wrapper.position === 'relative', true)
check('wrapper holds exactly account + bell + miniprogram', footer.wrapper.children.join('|'),
'user-menu-trigger user-menu-trigger--workbuddy|msg-center-bell-btn user-menu-trigger-msg-center|user-menu-trigger-miniprogram')
check('footer has no collapse entry any more', footer.collapseInFooter, [])
check('account trigger matches min-height, padding and radius', footer.trigger.minHeight === '40px' && footer.trigger.padding === '6px 8px' && footer.trigger.borderRadius === '8px', true)
check('account trigger keeps the recovered background transition', footer.trigger.transition, 'background-color 0.2s')
check('account trigger squeezes to the recovered width', Math.round(footer.trigger.box[2]), 191)
check('trigger left group keeps gap 10', footer.left.gap, '10px')
check('avatar is a 28x28 circle with the recovered initial colour', Math.round(footer.avatar.box[2]) === 28 && Math.round(footer.avatar.box[3]) === 28 && footer.avatar.radius === '50%' && footer.avatar.background === 'rgb(0, 86, 212)', true)
check('name follows 12/600/12 with ellipsis', footer.name.font === '12px/12px' && footer.name.weight === '600' && footer.name.overflow === 'hidden' && footer.name.textOverflow === 'ellipsis' && footer.name.whiteSpace === 'nowrap', true)
check('bell is 32x32 with radius 6 and the secondary colour', Math.round(footer.bell.box[2]) === 32 && Math.round(footer.bell.box[3]) === 32 && footer.bell.radius === '6px' && footer.bell.color === 'rgba(0, 0, 0, 0.7)', true)
check('bell keeps the recovered hover transition', footer.bell.transition, 'background-color, color 0.15s, 0.15s')
check('miniprogram button matches 32x32 radius 6', Math.round(footer.mini.box[2]) === 32 && Math.round(footer.mini.box[3]) === 32 && footer.mini.radius === '6px', true)
check('unread badge keeps the recovered geometry', footer.badge.minWidth === '14px' && footer.badge.height === '14px' && footer.badge.radius === '7px' && footer.badge.font === '9px/14px', true)
check('unread badge keeps the recovered colours', footer.badge.background === 'rgb(246, 64, 65)' && footer.badge.color === 'rgb(255, 255, 255)', true)
check('unread badge keeps top 2 / right 2 with translate(40%,-30%)', footer.badge.top === '2px' && footer.badge.right === '2px' && /matrix\(1, 0, 0, 1, 5\.6, -4\.2\)/.test(footer.badge.transform), true)
await step('footer-hover')
main.focus(); main.webContents.focus()
await pause(150)
// 用 CDP Input 域驱动真实指针,确保 CSS :hover 生效(sendInputEvent 在无焦点窗口下不可靠)。
const hover = async (selector) => {
const center = await read(`(()=>{const box=document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect();return {x:Math.round(box.x+box.width/2),y:Math.round(box.y+box.height/2)}})()`)
await main.webContents.debugger.sendCommand('Input.dispatchMouseEvent', { type: 'mouseMoved', x: center.x, y: center.y, button: 'none' })
await pause(250)
}
await hover('.msg-center-bell-btn')
await pause(300)
// 无合成帧环境下 transition 停在起始值(透明),先把「冻结」状态留档,再关掉过渡读取目标色。
snapshots.bellHoverFrozen = await read(`(()=>{const bell=document.querySelector('.msg-center-bell-btn');const style=getComputedStyle(bell);
return {hover:bell.matches(':hover'),background:style.backgroundColor,inline:bell.style.backgroundColor||null,
animations:[...document.getAnimations()].filter(animation=>animation.effect&&animation.effect.target===bell).map(animation=>({property:animation.transitionProperty||null,playState:animation.playState,currentTime:animation.currentTime}))}})()`)
snapshots.hoverDiag = await read(`(()=>{const bell=document.querySelector('.msg-center-bell-btn');const trigger=document.querySelector('.user-menu-trigger--workbuddy');const bs=getComputedStyle(bell);const ts=getComputedStyle(trigger);
return {bell:bell.matches(':hover'),trigger:trigger.matches(':hover'),focus:document.hasFocus(),active:document.activeElement?document.activeElement.tagName:null,
bellVars:{ghost:bs.getPropertyValue('--wb-button-ghost-bg-hover'),ycHover:bs.getPropertyValue('--yc-bg-hover'),wbHover:bs.getPropertyValue('--wb-bg-hover')},
triggerVars:{wbHover:ts.getPropertyValue('--wb-bg-hover'),palette:ts.getPropertyValue('--wb-palette-black-100'),ycHover:ts.getPropertyValue('--yc-bg-hover'),bg:ts.backgroundColor}}})()`)
snapshots.varProbe = await read(`(()=>{const bell=document.querySelector('.msg-center-bell-btn');const aside=bell.closest('aside.sidebar');
const chain=[];for(let node=bell;node;node=node.parentElement){chain.push(node.tagName.toLowerCase()+(node.className?'.'+String(node.className).split(/\\s+/).join('.'):''))}
return {chain,asideFound:!!aside,
asideBgHover:aside?getComputedStyle(aside).getPropertyValue('--wb-bg-hover'):null,
bellGhostHover:getComputedStyle(bell).getPropertyValue('--wb-button-ghost-bg-hover'),
bellIconPrimary:getComputedStyle(bell).getPropertyValue('--wb-icon-primary')}})()`)
snapshots.bellRules = await read(`(()=>{const out=[];for(const sheet of document.styleSheets){let rules;try{rules=sheet.cssRules}catch{continue}for(const rule of rules){if(!rule.selectorText||!/msg-center-bell-btn/.test(rule.selectorText))continue;out.push({selector:rule.selectorText,background:rule.style.background,backgroundColor:rule.style.backgroundColor,important:rule.style.getPropertyPriority('background-color'),href:(sheet.href||'inline').split('/').pop()})}}return out})()`)
await read(`(()=>{const style=document.createElement('style');style.id='qa-no-transitions';style.textContent='*{transition:none!important}';document.head.appendChild(style);return true})()`)
await pause(120)
snapshots.hoverTokens = await read(`(()=>{const host=document.querySelector('.conversation-list-footer');
const probe=(property,token)=>{const el=document.createElement('div');el.style[property]='var('+token+')';host.appendChild(el);const value=getComputedStyle(el)[property];el.remove();return value};
return {bgHover:probe('backgroundColor','--wb-bg-hover'),ghostHover:probe('backgroundColor','--wb-button-ghost-bg-hover'),iconPrimary:probe('color','--wb-icon-primary')}})()`)
check('recovered --wb-bg-hover resolves to the 5% black fill', snapshots.hoverTokens.bgHover,
value => /^(color\(srgb 0 0 0 \/ 0\.05\)|rgba\(0, 0, 0, 0\.05\))$/.test(String(value)))
check('recovered --wb-button-ghost-bg-hover resolves to the ~6% black fill', snapshots.hoverTokens.ghostHover,
value => /^rgba\(0, 0, 0, 0\.0(58|59|6)\)$/.test(String(value)))
await hover('.msg-center-bell-btn')
snapshots.bellHover = await read(`(()=>{const bell=document.querySelector('.msg-center-bell-btn');const style=getComputedStyle(bell);return {hover:bell.matches(':hover'),background:style.backgroundColor,color:style.color}})()`)
check('bell hover uses --wb-button-ghost-bg-hover', snapshots.bellHover.background, snapshots.hoverTokens.ghostHover)
check('bell hover switches the icon to --wb-icon-primary', snapshots.bellHover.color, snapshots.hoverTokens.iconPrimary)
await hover('.user-menu-trigger--workbuddy')
snapshots.triggerHover = await read(`(()=>{const trigger=document.querySelector('.user-menu-trigger--workbuddy');const style=getComputedStyle(trigger);return {hover:trigger.matches(':hover'),background:style.backgroundColor}})()`)
check('account trigger hover uses --wb-bg-hover', snapshots.triggerHover.background, snapshots.hoverTokens.bgHover)
snapshots.colorMixProbe = await read(`(()=>{const probe=document.createElement('div');probe.style.color='color-mix(in srgb, #000 5%, transparent)';document.body.appendChild(probe);const value=getComputedStyle(probe).color;probe.remove();return {probe:value,recoveredBgHover:getComputedStyle(document.querySelector('.user-menu-trigger--workbuddy')).getPropertyValue('--wb-bg-hover'),recoveredGhostHover:getComputedStyle(document.querySelector('.msg-center-bell-btn')).getPropertyValue('--wb-button-ghost-bg-hover')}})()`)
const capture = await main.webContents.debugger.sendCommand('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false })
await fs.writeFile(path.join(directory, 'sidebar-footer.png'), Buffer.from(capture.data, 'base64'))
await step('collapse-lives-in-topbar')
const topbar = await read(`(()=>{const button=document.querySelector('.titlebar__leading [title="收起侧边栏"]');if(!button)return null;const box=button.getBoundingClientRect();return {box:[box.x,box.y,box.width,box.height]}})()`)
check('titlebar still owns the collapse entry', !!topbar, true)
check('titlebar collapse button keeps the 24x24 topbar size', topbar && Math.round(topbar.box[2]) === 24 && Math.round(topbar.box[3]) === 24, true)
await read(`document.querySelector('.titlebar__leading [title="收起侧边栏"]').click()`)
await wait(`!!document.querySelector('.sidebar--collapsed')`, 'sidebar collapsed')
check('titlebar collapse entry still collapses the sidebar', await read(`!!document.querySelector('.sidebar--collapsed')`), true)
check('collapsed rail keeps its own expand entry', await read(`!!document.querySelector('.sidebar__rail [title="展开侧栏"]')`), true)
check('no renderer error logs', rendererErrors, [])
const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL',
scope: 'Real isolated YCode App/preload/IPC/renderer; footer DOM, computed styles, geometry, hover fill and topbar collapse entry measured in the live window.',
sourceEvidence: ['ev_721d830c97114930e046ec8931bd2f473a11ab23efcab9c727c4256889e29879', 'contexts-BkjwYSZT.css', 'UserMenu-CpHz-0rF.css'],
snapshots, checks, rendererErrors }
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
console.log('SIDEBAR_FOOTER_' + report.result + ' ' + directory)
for (const failure of checks.filter(item => !item.pass)) console.log('SIDEBAR_FOOTER_FAILURE ' + JSON.stringify(failure))
app.exit(report.result === 'PASS' ? 0 : 1)
})().catch(async error => {
await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({ phase, error: String(error), stack: error.stack, checks, snapshots, rendererErrors }, null, 2))
console.error(error); app.exit(1)
})
}