feat(sidebar): replicate WorkBuddy account menu (TODO 09)
This commit is contained in:
1 parent
6173a77918
commit
cb8a7e6466
9 files changed
+1035
-6
No files matched your search
@@ -0,0 +1,314 @@
|
||||
// 真实隔离 YCode App + 真实 preload/IPC/渲染进程,验证侧栏账户菜单(TODO 09)。
|
||||
// 原版期望值来自固定 REA 归档(ev_95a39af88081a641961779c55d5855e0e3d57889e5d2ccad2c3dc15e26e1c8bb:
|
||||
// WorkBuddy 5.8.3 账户菜单打开状态,视口 1336×1240;辅助证据 ev_4162472c…/ev_c9144986…)。
|
||||
// 几何契约:popover [8,607,320,577]、placement-top(左边缘与触发器对齐、底边距触发器上沿 8px)、
|
||||
// padding 8、radius 16、gap 8、背景 rgb(247,247,247)(深色 #292929);
|
||||
// 卡片 .user-menu-section 302 宽 padding 8 radius 8 背景 rgb(255,255,255)(深色 #242424);
|
||||
// 头部 56 高(头像 40×40、名称 16/500/24);计费单元 141×86(title 12/20、value 14/500/22、动作按钮 24 高);
|
||||
// 条目 32 高 padding 5px 8px radius 8 gap 8 space-between(图标 16×16);footer margin-top 6 + padding-top 8 + 1px 线。
|
||||
// 内容范围按用户指定:头像与名称、套餐、余额、升级套餐、购买积分、设置、外观、记忆与进化、检查更新、退出登录。
|
||||
// 原版 736 个布局节点里没有遮罩层节点,因此本实现不做全屏 overlay:外部点击既不吞第一次交互,也要关闭菜单。
|
||||
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/account-menu-'))
|
||||
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: 'account-menu-live', title: 'Account Menu Live', cwd, createdAt: now, updatedAt: now + 1, messages: [
|
||||
{ id: 'account-menu-user', role: 'user', content: '账户菜单验收', createdAt: now, status: 'done', requestId: 'account-menu-run' },
|
||||
{ id: 'account-menu-assistant', role: 'assistant', content: '收到。', createdAt: now + 1, status: 'done', requestId: 'account-menu-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('ACCOUNT_MENU_')) process.stdout.write(bytes) })
|
||||
child.stderr.on('data', bytes => { output += bytes })
|
||||
const timer = setTimeout(() => child.kill(), 240000)
|
||||
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('account-menu-'))
|
||||
app.setPath('userData', path.join(directory, 'data')); app.setPath('sessionData', path.join(directory, 'data'))
|
||||
app.setPath('home', path.join(directory, 'home'))
|
||||
let main, settings, phase = 'boot'
|
||||
const checks = [], snapshots = {}, rendererErrors = []
|
||||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
async function read(source, win = main) {
|
||||
let timer
|
||||
try {
|
||||
return await Promise.race([win.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, win = main) {
|
||||
for (let index = 0; index < 250; index++) { const value = await read(source, win); 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 })
|
||||
}
|
||||
function observe(win) { win.webContents.on('console-message', (_event, level, message) => { if (level >= 2) rendererErrors.push({ windowId: win.id, message }) }) }
|
||||
async function shot(name, win = main) {
|
||||
if (!win.webContents.debugger.isAttached()) win.webContents.debugger.attach('1.3')
|
||||
const image = await win.webContents.debugger.sendCommand('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false })
|
||||
await fs.writeFile(path.join(directory, name + '.png'), Buffer.from(image.data, 'base64'))
|
||||
}
|
||||
async function openSettings(section) {
|
||||
await read(`window.ycode.app.openSettings(${JSON.stringify(section)})`)
|
||||
for (let index = 0; index < 200; index++) {
|
||||
settings = BrowserWindow.getAllWindows().find(win => win !== main)
|
||||
if (settings && !settings.webContents.isLoading()) break
|
||||
await pause(25)
|
||||
}
|
||||
assert.ok(settings, 'native settings window')
|
||||
await wait('!!document.querySelector(".settings-window-root")', 'settings root', settings)
|
||||
return settings
|
||||
}
|
||||
async function clickText(selector, text, win = main) {
|
||||
await read(`(()=>{const node=[...document.querySelectorAll(${JSON.stringify(selector)})].find(item=>item.textContent.trim()===${JSON.stringify(text)});if(!node)throw Error('Missing text button: '+${JSON.stringify(text)});node.click()})()`, win)
|
||||
await pause(80)
|
||||
}
|
||||
// 真实鼠标事件(CDP Input):会依次派发 pointerdown/mousedown/click,用于验证「外部点击」这类依赖 pointerdown 的关闭逻辑。
|
||||
async function mouseClick(selector, win = main) {
|
||||
const point = await read(`(()=>{const node=document.querySelector(${JSON.stringify(selector)});if(!node)return null;const box=node.getBoundingClientRect();return [box.x+box.width/2,box.y+box.height/2]})()`, win)
|
||||
assert.ok(point, 'mouse target: ' + selector)
|
||||
if (!win.webContents.debugger.isAttached()) win.webContents.debugger.attach('1.3')
|
||||
await win.webContents.debugger.sendCommand('Input.dispatchMouseEvent', { type: 'mousePressed', x: point[0], y: point[1], button: 'left', clickCount: 1 })
|
||||
await win.webContents.debugger.sendCommand('Input.dispatchMouseEvent', { type: 'mouseReleased', x: point[0], y: point[1], button: 'left', clickCount: 1 })
|
||||
await pause(80)
|
||||
}
|
||||
|
||||
const openMenu = `(()=>{const trigger=document.querySelector('.user-menu-trigger--workbuddy');if(!trigger)return false;trigger.click();return true})()`
|
||||
const clickRow = label => `(()=>{const rows=[...document.querySelectorAll('.ycode-account-menu .user-menu-item')];const match=rows.find(row=>row.textContent.includes(${JSON.stringify(label)}));if(!match)return false;match.click();return true})()`
|
||||
const rowSource = `(()=>{const rows=[...document.querySelectorAll('.ycode-account-menu .user-menu-item')];return rows.map(row=>({text:row.textContent.replace(/\\s+/g,' ').trim(),ariaDisabled:row.getAttribute('aria-disabled'),role:row.getAttribute('role')}))})()`
|
||||
|
||||
const popoverSource = `(()=>{const trigger=document.querySelector('.user-menu-trigger--workbuddy');const pop=document.querySelector('.ycode-account-menu');if(!trigger||!pop)return null;
|
||||
const style=getComputedStyle(pop);const rect=pop.getBoundingClientRect();const t=trigger.getBoundingClientRect();
|
||||
const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]};
|
||||
const font=node=>{const s=getComputedStyle(node);return s.fontSize+'/'+s.fontWeight+'/'+s.lineHeight};
|
||||
const sections=[...pop.querySelectorAll(':scope > .user-menu-section')];
|
||||
const header=pop.querySelector('.user-menu-header'),avatar=pop.querySelector('.user-menu-header-avatar'),avatarInitial=pop.querySelector('.user-menu-header-avatar .user-menu-avatar-initial'),name=pop.querySelector('.user-menu-header-name-text');
|
||||
const cells=[...pop.querySelectorAll('.user-menu-billing-cell')];
|
||||
const items=[...pop.querySelectorAll('.user-menu-items .user-menu-item')];
|
||||
const footer=pop.querySelector('.user-menu-footer'),logout=pop.querySelector('.user-menu-item--logout');
|
||||
return {viewport:[innerWidth,innerHeight],theme:document.documentElement.dataset.theme??'light',
|
||||
trigger:{box:box(trigger),left:t.left,top:t.top,haspopup:trigger.getAttribute('aria-haspopup'),expanded:trigger.getAttribute('aria-expanded')},
|
||||
popover:{box:box(pop),left:rect.left,bottom:rect.bottom,width:rect.width,height:rect.height,padding:style.padding,gap:style.rowGap,radius:style.borderRadius,
|
||||
background:style.backgroundColor,borderWidth:style.borderTopWidth,borderColor:style.borderTopColor,shadow:style.boxShadow,overflowY:style.overflowY,overflowX:style.overflowX,
|
||||
maxHeight:style.maxHeight,maxWidth:style.maxWidth,position:style.position,zIndex:style.zIndex,display:style.display,role:pop.getAttribute('role'),label:pop.getAttribute('aria-label')},
|
||||
sections:sections.map(node=>({box:box(node),padding:getComputedStyle(node).padding,radius:getComputedStyle(node).borderRadius,background:getComputedStyle(node).backgroundColor})),
|
||||
header:header?{box:box(header),minHeight:getComputedStyle(header).minHeight,padding:getComputedStyle(header).padding,gap:getComputedStyle(header).gap,display:getComputedStyle(header).display}:null,
|
||||
avatar:avatar?{box:box(avatar),radius:getComputedStyle(avatar).borderRadius}:null,
|
||||
avatarInitial:avatarInitial?{box:box(avatarInitial),radius:getComputedStyle(avatarInitial).borderRadius,background:getComputedStyle(avatarInitial).backgroundColor}:null,
|
||||
name:name?{text:name.textContent,font:font(name),color:getComputedStyle(name).color}:null,
|
||||
cells:cells.map(cell=>{const title=cell.querySelector('.user-menu-billing-cell-title'),value=cell.querySelector('.user-menu-billing-cell-value'),
|
||||
action=cell.querySelector('.user-menu-billing-cell-action'),button=cell.querySelector('button');
|
||||
return {box:box(cell),padding:getComputedStyle(cell).padding,
|
||||
title:title?{text:title.textContent,font:font(title),color:getComputedStyle(title).color}:null,
|
||||
value:value?{text:value.textContent,font:font(value)}:null,
|
||||
action:action?{box:box(action),marginTop:getComputedStyle(action).marginTop}:null,
|
||||
button:button?{text:button.textContent.trim(),box:box(button),height:parseFloat(getComputedStyle(button).height),disabled:button.disabled,title:button.getAttribute('title')}:null}}),
|
||||
items:items.map(item=>{const icon=item.querySelector('.user-menu-item-icon'),svg=item.querySelector('.user-menu-item-icon svg'),
|
||||
label=item.querySelector('.user-menu-item-label'),value=item.querySelector('.user-menu-item-value'),s=getComputedStyle(item);
|
||||
return {label:label?label.textContent:null,value:value?value.textContent:null,box:box(item),padding:s.padding,radius:s.borderRadius,gap:s.gap,justify:s.justifyContent,font:font(item),
|
||||
icon:icon?box(icon):null,svg:svg?box(svg):null,role:item.getAttribute('role'),ariaDisabled:item.getAttribute('aria-disabled'),disabled:item.disabled===true}}),
|
||||
itemsGap:getComputedStyle(pop.querySelector('.user-menu-items')).gap,
|
||||
footer:footer?{box:box(footer),marginTop:getComputedStyle(footer).marginTop,paddingTop:getComputedStyle(footer).paddingTop,ruleHeight:getComputedStyle(footer,'::before').height,ruleColor:getComputedStyle(footer,'::before').backgroundColor}:null,
|
||||
logout:logout?{text:logout.textContent.replace(/\\s+/g,' ').trim(),box:box(logout),font:font(logout)}:null,
|
||||
digits:/[0-9]/.test(pop.textContent.replace(/Ctrl,\\s*/g,'')),
|
||||
text:pop.textContent.replace(/\\s+/g,' ').trim()}})()`
|
||||
|
||||
;(async () => {
|
||||
const { pathToFileURL } = require('node:url')
|
||||
await 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')
|
||||
observe(main)
|
||||
await wait('!!document.querySelector("#root")', 'renderer root')
|
||||
await pause(1500)
|
||||
await wait(`!!document.querySelector('.user-menu-trigger--workbuddy')`, 'account trigger')
|
||||
check('account trigger exposes the menu semantics', await read(`(()=>{const trigger=document.querySelector('.user-menu-trigger--workbuddy');return [trigger.getAttribute('aria-haspopup'),trigger.getAttribute('aria-expanded')]})()`), ['menu', 'false'])
|
||||
|
||||
await step('menu-open-light')
|
||||
assert.equal(await read(openMenu), true)
|
||||
await wait('!!document.querySelector(".ycode-account-menu")', 'account menu open')
|
||||
await pause(200)
|
||||
const light = await read(popoverSource)
|
||||
snapshots.light = light
|
||||
check('popover keeps the recovered 320px width', light.popover.width, 320)
|
||||
check('popover is placement-top: left edge aligned with the trigger', Math.abs(light.popover.left - light.trigger.left) < 0.5, true)
|
||||
check('popover sits 8px above the trigger', Math.round(light.trigger.top - light.popover.bottom), 8)
|
||||
check('popover keeps padding/radius/gap', [light.popover.padding, light.popover.radius, light.popover.gap], ['8px', '16px', '8px'])
|
||||
check('popover keeps the recovered light background', light.popover.background, 'rgb(247, 247, 247)')
|
||||
// 原版声明为 .5px,Chromium 计算值即 1px(ev_95a39af8… 记录 border-top-width=1px)。
|
||||
check('popover keeps the recovered hairline border', [light.popover.borderWidth, light.popover.borderColor], ['1px', 'rgb(222, 222, 222)'])
|
||||
check('popover keeps the recovered two-layer shadow', light.popover.shadow, 'rgba(0, 0, 0, 0.04) 0px 6px 24px 0px, rgba(0, 0, 0, 0.04) 0px 4px 6px 0px')
|
||||
check('popover scrolls like the original', light.popover.overflowY === 'auto' && light.popover.overflowX === 'hidden', true)
|
||||
check('popover keeps max-height calc(100vh - 32px)', light.popover.maxHeight, `${light.viewport[1] - 32}px`)
|
||||
check('popover keeps max-width calc(100vw - 24px)', light.popover.maxWidth, `${light.viewport[0] - 24}px`)
|
||||
check('popover is a fixed menu layer above the shell', light.popover.position === 'fixed' && Number(light.popover.zIndex) >= 9999, true)
|
||||
check('popover is exposed as an accessible menu', [light.popover.role, light.popover.label], ['menu', '账户菜单'])
|
||||
check('popover has three 302-wide cards with 8px padding', light.sections.map(section => [Math.round(section.box[2]), section.padding]), [[302, '8px'], [302, '8px'], [302, '8px']])
|
||||
check('cards keep radius 8 on the recovered white fill', light.sections.map(section => [section.radius, section.background]), [['8px', 'rgb(255, 255, 255)'], ['8px', 'rgb(255, 255, 255)'], ['8px', 'rgb(255, 255, 255)']])
|
||||
check('header keeps the 56px min-height with 8px padding', [light.header.minHeight, light.header.padding, light.header.gap], ['56px', '8px', '8px'])
|
||||
check('header avatar box is 40x40 like the original wrapper', [light.avatar.box[2], light.avatar.box[3]], [40, 40])
|
||||
check('header avatar initial is round on the recovered #0056d4 fill', [light.avatarInitial.radius, light.avatarInitial.background], ['50%', 'rgb(0, 86, 212)'])
|
||||
check('header shows the signed-in mock account name', light.name.text, 'xiaohan.yuan')
|
||||
check('header name keeps 16/500/24 typography', light.name.font, '16px/500/24px')
|
||||
check('billing summary keeps two 141-wide cells', light.cells.map(cell => Math.round(cell.box[2])), [141, 141])
|
||||
check('billing cells keep the recovered padding/typography', light.cells.map(cell => [cell.padding, cell.title.font, cell.value.font]), [['4px 8px 8px', '12px/400/20px', '14px/500/22px'], ['4px 8px 8px', '12px/400/20px', '14px/500/22px']])
|
||||
check('billing cells are titled 套餐/余额', light.cells.map(cell => cell.title.text), ['套餐', '余额'])
|
||||
check('billing actions keep the 24px row with an 8px top margin', light.cells.map(cell => [cell.action.marginTop, Math.round(cell.button.height)]), [['8px', 24], ['8px', 24]])
|
||||
check('upgrade/purchase entries say 升级套餐/购买积分', light.cells.map(cell => cell.button.text), ['升级套餐', '购买积分'])
|
||||
check('unwired billing actions are disabled, not fake-successful', light.cells.map(cell => cell.button.disabled), [true, true])
|
||||
check('billing values do not invent 套餐/余额 numbers', light.cells.map(cell => cell.value.text), ['未接入', '未接入'])
|
||||
check('menu items keep the recovered 32px row', light.items.map(item => [item.label, Math.round(item.box[3]), item.padding, item.radius, item.gap, item.justify]),
|
||||
[['设置', 32, '5px 8px', '8px', '8px', 'space-between'], ['外观', 32, '5px 8px', '8px', '8px', 'space-between'],
|
||||
['记忆与进化', 32, '5px 8px', '8px', '8px', 'space-between'], ['检查更新', 32, '5px 8px', '8px', '8px', 'space-between']])
|
||||
check('menu items keep 14/400/22 typography', light.items.map(item => item.font), ['14px/400/22px', '14px/400/22px', '14px/400/22px', '14px/400/22px'])
|
||||
check('menu items keep 16x16 icon boxes', light.items.map(item => item.icon && [item.icon[2], item.icon[3]]), [[16, 16], [16, 16], [16, 16], [16, 16]])
|
||||
// 原版容器 role="menu",条目显式 role="button"(ev_95a39af8… 的 attributes 只记录 class/id/role)。
|
||||
check('menu items keep the recovered role=button', light.items.map(item => item.role), ['button', 'button', 'button', 'button'])
|
||||
check('menu items keep the recovered 2px gap', light.itemsGap, '2px')
|
||||
check('settings row shows the Ctrl+, shortcut', light.items[0].value, 'Ctrl+,')
|
||||
check('appearance row reports the current light theme', light.items[1].value, '浅色')
|
||||
check('unwired rows stay honest about their state', [light.items[2].value, light.items[3].value], ['待拆解', '未接入'])
|
||||
check('footer keeps margin-top 6 + padding-top 8 + 1px rule', [light.footer.marginTop, light.footer.paddingTop, light.footer.ruleHeight], ['6px', '8px', '1px'])
|
||||
check('logout row keeps the 32px row inside the footer', [Math.round(light.logout.box[3]), light.logout.font], [32, '14px/400/22px'])
|
||||
check('logout entry is present and honest', /退出登录.*未接入/.test(light.logout.text), true)
|
||||
check('menu content matches the requested scope', light.text,
|
||||
'Xxiaohan.yuan账号为 mock 数据,尚未接入登录套餐未接入升级套餐余额未接入购买积分设置Ctrl+,外观浅色记忆与进化待拆解检查更新未接入退出登录未接入')
|
||||
check('menu shows no fabricated 套餐/余额/积分 numbers', light.digits, false)
|
||||
await shot('account-menu-light')
|
||||
|
||||
await step('menu-escape-close')
|
||||
await read(`document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}))`)
|
||||
await wait('!document.querySelector(".ycode-account-menu")', 'menu closed by Escape')
|
||||
check('Escape closes the account menu', await read('!document.querySelector(".ycode-account-menu")'), true)
|
||||
check('Escape returns focus to the account trigger', await read(`document.activeElement===document.querySelector('.user-menu-trigger--workbuddy')`), true)
|
||||
check('trigger reports collapsed state after close', await read(`document.querySelector('.user-menu-trigger--workbuddy').getAttribute('aria-expanded')`), 'false')
|
||||
|
||||
await step('menu-trigger-toggle')
|
||||
await read(openMenu)
|
||||
await wait('!!document.querySelector(".ycode-account-menu")', 'menu reopened')
|
||||
await read(openMenu)
|
||||
await wait('!document.querySelector(".ycode-account-menu")', 'menu toggled closed')
|
||||
check('clicking the trigger again closes the menu', await read('!document.querySelector(".ycode-account-menu")'), true)
|
||||
|
||||
await step('menu-outside-click-close')
|
||||
await read(openMenu)
|
||||
await wait('!!document.querySelector(".ycode-account-menu")', 'menu reopened for outside click')
|
||||
await mouseClick('.msg-center-bell-btn')
|
||||
await wait('!document.querySelector(".ycode-account-menu")', 'menu closed by outside click')
|
||||
await wait('!!document.querySelector(".notif-panel")', 'notification panel opened by the same click')
|
||||
check('outside click closes the menu without swallowing the click', [await read('!document.querySelector(".ycode-account-menu")'), await read('!!document.querySelector(".notif-panel")')], [true, true])
|
||||
await read(`document.querySelector('.notif-panel button[title="关闭"]').click()`)
|
||||
await wait('!document.querySelector(".notif-panel")', 'notification panel closed')
|
||||
|
||||
await step('menu-appearance-navigation')
|
||||
await read(openMenu)
|
||||
await wait('!!document.querySelector(".ycode-account-menu")', 'menu open for appearance')
|
||||
assert.equal(await read(clickRow('外观')), true)
|
||||
await wait('!document.querySelector(".ycode-account-menu")', 'menu closed after appearance click')
|
||||
await openSettings('appearance')
|
||||
observe(settings)
|
||||
await wait('!!document.querySelector(".segmented")', 'appearance panel', settings)
|
||||
check('外观 row opens the real appearance panel', await read(`[...document.querySelectorAll('.segmented__item')].map(node=>node.textContent)`, settings), ['浅色', '深色'])
|
||||
check('appearance panel marks the current light theme', await read(`[...document.querySelectorAll('.segmented__item')].map(node=>[node.textContent,node.dataset.active])`, settings), [['浅色', 'true'], ['深色', 'false']])
|
||||
|
||||
await step('menu-dark-theme')
|
||||
await clickText('.segmented__item', '深色', settings)
|
||||
await wait(`document.documentElement.dataset.theme==='dark'`, 'main window receives dark theme')
|
||||
check('appearance row drives the shared theme', await read(`[document.documentElement.dataset.theme,localStorage.getItem('ycode:theme')]`), ['dark', 'dark'])
|
||||
await read(openMenu)
|
||||
await wait('!!document.querySelector(".ycode-account-menu")', 'menu open in dark theme')
|
||||
await pause(200)
|
||||
const dark = await read(popoverSource)
|
||||
snapshots.dark = dark
|
||||
check('dark popover uses the recovered #292929 fill', dark.popover.background, 'rgb(41, 41, 41)')
|
||||
check('dark cards use the recovered #242424 fill', dark.sections.map(section => section.background), ['rgb(36, 36, 36)', 'rgb(36, 36, 36)', 'rgb(36, 36, 36)'])
|
||||
check('dark cards keep radius 8 and 8px padding', dark.sections.map(section => [section.radius, section.padding]), [['8px', '8px'], ['8px', '8px'], ['8px', '8px']])
|
||||
check('appearance row follows the live dark theme', dark.items[1].value, '深色')
|
||||
check('dark menu keeps the same geometry', [dark.popover.width, Math.round(dark.trigger.top - dark.popover.bottom)], [320, 8])
|
||||
await shot('account-menu-dark')
|
||||
|
||||
await step('menu-settings-navigation')
|
||||
assert.equal(await read(clickRow('设置')), true)
|
||||
await wait('!document.querySelector(".ycode-account-menu")', 'menu closed after settings click')
|
||||
await wait('!!document.querySelector(".models-settings-panel")', 'models panel in the reused settings window', settings)
|
||||
check('设置 row reuses the native settings window', BrowserWindow.getAllWindows().filter(win => win !== main).length, 1)
|
||||
check('设置 row lands on the models panel', await read(`!!document.querySelector('.models-settings-panel')&&!document.querySelector('.segmented')`, settings), true)
|
||||
|
||||
await step('menu-light-restore')
|
||||
await read(`document.querySelector('[data-settings-tab="appearance"]').click()`, settings)
|
||||
await wait('!!document.querySelector(".segmented")', 'appearance panel again', settings)
|
||||
await clickText('.segmented__item', '浅色', settings)
|
||||
await wait(`document.documentElement.dataset.theme==='light'`, 'main window receives light theme')
|
||||
check('light theme is restored through the same settings window', await read(`[document.documentElement.dataset.theme,localStorage.getItem('ycode:theme')]`), ['light', 'light'])
|
||||
const settingsId = settings.id
|
||||
await read(`document.querySelector('.settings-window-close').click()`, settings)
|
||||
for (let index = 0; index < 200 && !settings.isDestroyed(); index++) await pause(20)
|
||||
check('settings window closes normally', settings.isDestroyed(), true)
|
||||
check('closed settings window is gone from the app windows', BrowserWindow.getAllWindows().filter(win => win.id === settingsId).length, 0)
|
||||
|
||||
await step('menu-unwired-rows')
|
||||
await read(openMenu)
|
||||
await wait('!!document.querySelector(".ycode-account-menu")', 'menu open for unwired rows')
|
||||
snapshots.unwiredRows = await read(rowSource)
|
||||
assert.equal(await read(clickRow('记忆与进化')), true)
|
||||
await wait('!document.querySelector(".ycode-account-menu")', 'menu closed after unwired row click')
|
||||
const hint = await wait(`(()=>{const node=document.querySelector('.sidebar__hint');return node?node.textContent:null})()`, 'sidebar hint for the unwired row')
|
||||
check('记忆与进化 reports a pending state instead of fake success', /待拆解|尚未接入/.test(hint), true)
|
||||
check('unwired row does not open an extra window', BrowserWindow.getAllWindows().filter(win => win !== main).length, 0)
|
||||
await wait('!document.querySelector(".sidebar__hint")', 'hint auto-dismissed')
|
||||
await read(openMenu)
|
||||
await wait('!!document.querySelector(".ycode-account-menu")', 'menu reopened for remaining rows')
|
||||
assert.equal(await read(clickRow('检查更新')), true)
|
||||
await wait('!document.querySelector(".ycode-account-menu")', 'menu closed after 检查更新')
|
||||
check('检查更新 reports an unwired state', /尚未接入/.test(await wait(`(()=>{const node=document.querySelector('.sidebar__hint');return node?node.textContent:null})()`, 'hint for 检查更新')), true)
|
||||
await wait('!document.querySelector(".sidebar__hint")', 'second hint auto-dismissed')
|
||||
await read(openMenu)
|
||||
await wait('!!document.querySelector(".ycode-account-menu")', 'menu open for logout')
|
||||
await shot('account-menu-final')
|
||||
// 记忆与进化 / 检查更新 / 退出登录 三行都要对辅助技术保持 disabled,不假装可用。
|
||||
check('unwired rows stay disabled for assistive tech', snapshots.unwiredRows.slice(2).map(row => row.ariaDisabled), ['true', 'true', '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; account popover DOM, computed styles, geometry, dismissal, settings navigation and theme interactivity measured in the live window.',
|
||||
sourceEvidence: ['ev_95a39af88081a641961779c55d5855e0e3d57889e5d2ccad2c3dc15e26e1c8bb', 'ev_4162472c…', 'ev_c9144986…', 'UserMenu-CpHz-0rF.css'],
|
||||
snapshots, checks, rendererErrors }
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
|
||||
console.log('ACCOUNT_MENU_' + report.result + ' ' + directory)
|
||||
for (const failure of checks.filter(item => !item.pass)) console.log('ACCOUNT_MENU_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