// 真实隔离 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) }) }