feat(sidebar): replicate WorkBuddy footer account row (TODO 07)
This commit is contained in:
1 parent
b212a3e867
commit
0b549ca794
5 files changed
+412
-101
No files matched your search
@@ -0,0 +1,205 @@
|
||||
// 真实隔离 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)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user