205 lines
20 KiB
JavaScript
205 lines
20 KiB
JavaScript
// 真实隔离 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_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)
|
||
})
|
||
}
|