Files
ycode/scripts/verify-account-menu.cjs
T

326 lines
28 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 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_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('memory is wired while the update row stays honest', [light.items[2].value, light.items[3].value], [null, '未接入'])
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(".appearance-panel")', 'appearance panel', settings)
check('外观 row opens the real appearance panel', await read(`[...document.querySelectorAll('.appearance-card')].map(node=>node.getAttribute('aria-label'))`, settings), ['浅色', '深色'])
check('appearance panel marks the current light theme', await read(`[...document.querySelectorAll('.appearance-card')].map(node=>[node.getAttribute('aria-label'),String(node.classList.contains('appearance-card--selected'))])`, settings), [['浅色', 'true'], ['深色', 'false']])
await step('menu-dark-theme')
await mouseClick('[aria-label="深色"].appearance-card', 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(".general-settings-panel")&&!document.querySelector(".appearance-panel")', 'general 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 general panel', await read(`!!document.querySelector('.general-settings-panel')&&!document.querySelector('.appearance-panel')`, settings), true)
await step('menu-light-restore')
await read(`document.querySelector('[data-settings-tab="appearance"]').click()`, settings)
await wait('!!document.querySelector(".appearance-panel")', 'appearance panel again', settings)
await mouseClick('[aria-label="浅色"].appearance-card', 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.isVisible(); index++) await pause(20)
check('settings window hides normally', !settings.isDestroyed() && !settings.isVisible(), true)
check('closed settings window stays cached', BrowserWindow.getAllWindows().filter(win => win.id === settingsId).length, 1)
await step('menu-memory-navigation')
await read(openMenu)
await wait('!!document.querySelector(".ycode-account-menu")', 'menu open for memory')
assert.equal(await read(clickRow('记忆与进化')), true)
await wait('!document.querySelector(".ycode-account-menu")', 'menu closed after memory click')
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, 'memory settings window')
observe(settings)
await wait('!!document.querySelector(".memory-settings-panel")', 'memory settings panel', settings)
check('记忆与进化 row opens the real memory settings panel', await read(`!!document.querySelector('.memory-settings-panel')&&!document.querySelector('.appearance-panel')`, settings), true)
check('memory row reuses one native settings window', BrowserWindow.getAllWindows().filter(win => win !== main).length, 1)
const memorySettingsId = settings.id
await read(`document.querySelector('.settings-window-close').click()`, settings)
for (let index = 0; index < 200 && settings.isVisible(); index++) await pause(20)
check('memory settings window hides normally', !settings.isVisible() && BrowserWindow.getAllWindows().some(win => win.id === memorySettingsId), true)
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 检查更新')
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('only the still-unwired rows are disabled for assistive tech', snapshots.unwiredRows.slice(2).map(row => row.ariaDisabled), [null, '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)
})
}