Files
ycode/scripts/verify-task-menu.cjs
T
xiaohan.yuan e335588826 feat(sidebar): replicate WorkBuddy task context menu (TODO 01)
Replace the sidebar delete button with the original task menu: pin/unpin,
rename, open in a separate window, open folder, archive and delete.

Source: REA ev_f5e9d71a676c453daab40928acfb33aeabae68ba57ffda342380cead7f1a1fe2
(WorkBuddy 5.8.3 task menu, open state) supplies the container/item geometry,
the two dividers, danger colour and every item icon.

Verified by scripts/verify-task-menu.cjs: 33 assertions in a real isolated
Electron app (real preload/IPC/filesystem), including menu geometry, anchor
offset, pin/rename/archive persistence, open-folder target and the separate
window query. typecheck, test:conversation and build pass.
2026-10-17 04:43:56 +08:00

182 lines
14 KiB
JavaScript

// 真实隔离 YCode App + 真实 preload/IPC/文件系统,验证侧栏任务操作菜单。
// 原版期望值来自固定 REA 归档(ev_f5e9d71a…:WorkBuddy 5.8.3 任务菜单打开状态),不从本次运行的 YCode 反推。
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/task-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)
await fs.writeFile(path.join(cwd, 'TASK_MENU_SENTINEL.txt'), 'TASK_MENU_NATIVE_SENTINEL')
const now = Date.now()
const crypto = require('node:crypto')
const sessions = ['Menu Alpha', 'Menu Beta'].map((title, index) => ({
id: crypto.randomUUID(), title, cwd, createdAt: now + index, updatedAt: now + index,
messages: [{ id: crypto.randomUUID(), role: 'user', content: title, createdAt: now + index, status: 'done' }]
}))
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', YCODE_QA_OFFSCREEN: '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('TASK_MENU_')) process.stdout.write(bytes) })
child.stderr.on('data', bytes => { output += bytes })
const timer = setTimeout(() => child.kill(), 150000)
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('task-menu-'))
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 })
}
async function shot(name) {
const capture = await main.webContents.debugger.sendCommand('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false })
await fs.writeFile(path.join(directory, name + '.png'), Buffer.from(capture.data, 'base64'))
}
// 真实用户路径:先悬停行让操作按钮显形,再点击按钮。
const openMenu = `(()=>{const row=document.querySelector('[data-session-id]');if(!row)return null;row.classList.add('sidebar__item--menu-open');const button=row.querySelector('.agent-card-more-button');if(!button)return null;button.click();return true})()`
const menuSource = `(()=>{const menu=document.querySelector('.conversation-context-menu');if(!menu)return null;const style=getComputedStyle(menu);const rect=menu.getBoundingClientRect();return {rect:[rect.x,rect.y,rect.width,rect.height],background:style.backgroundColor,borderRadius:style.borderRadius,padding:style.padding,items:[...menu.querySelectorAll('.cb-context-menu-item')].map(button=>{const box=button.getBoundingClientRect();const icon=button.querySelector('.cb-context-menu-item-icon svg');const label=button.querySelector('.cb-context-menu-item-label');const itemStyle=getComputedStyle(button);return {label:label?label.textContent:'',width:box.width,height:box.height,fontSize:itemStyle.fontSize,lineHeight:itemStyle.lineHeight,borderRadius:itemStyle.borderRadius,color:itemStyle.color,icon:icon?[icon.getBoundingClientRect().width,icon.getBoundingClientRect().height]:null}}),dividers:[...menu.querySelectorAll('.cb-context-menu-divider')].map(divider=>{const box=divider.getBoundingClientRect();const dividerStyle=getComputedStyle(divider);return {width:box.width,height:box.height,margin:dividerStyle.margin}})}})()`
const clickItemByLabel = label => `(()=>{const items=[...document.querySelectorAll('.cb-context-menu-item')];const match=items.find(item=>item.textContent.includes(${JSON.stringify(label)}));if(!match)return false;match.click();return true})()`
;(async () => {
// 关闭“打开文件夹”的系统副作用:拦截 shell.openPath,记录真实传入路径。
const { shell } = require('electron')
let openedFolder = null
shell.openPath = async (target) => { openedFolder = target; return '' }
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')
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 wait('!!document.querySelector("[data-session-id]")', 'sidebar rows')
await step('menu-open')
await read(openMenu)
const menu = await wait(menuSource, 'task context menu')
snapshots.menu = menu
check('menu labels match the implemented subset', menu.items.map(item => item.label), ['置顶', '重命名', '在独立窗口中打开', '打开文件夹', '归档', '删除任务'])
check('menu uses two dividers like the original', menu.dividers.length, 2)
check('items are 32 high like the original', menu.items.every(item => Math.round(item.height) === 32), true)
check('item font is 14/22 like the original', menu.items.every(item => item.fontSize === '14px' && item.lineHeight === '22px'), true)
check('item radius is 8 like the original', menu.items.every(item => item.borderRadius === '8px'), true)
check('icon box is 16x16 like the original', menu.items.every(item => item.icon && Math.round(item.icon[0]) === 16 && Math.round(item.icon[1]) === 16), true)
check('menu padding is 8 like the original', menu.padding, '8px')
check('menu radius is 16 like the original', menu.borderRadius, '16px')
check('danger item keeps the original danger colour', menu.items.at(-1).color, 'rgb(180, 44, 63)')
check('normal items keep the original text colour', menu.items[0].color, 'rgba(0, 0, 0, 0.9)')
check('menu minimum width is 160 like the original', menu.rect[2] >= 160, true)
check('menu sits on the original anchor offset (button left - 17, bottom + 4)', await read(`(()=>{const row=document.querySelector('[data-session-id]');const button=row.querySelector('.agent-card-more-button');const menu=document.querySelector('.conversation-context-menu');const b=button.getBoundingClientRect(),m=menu.getBoundingClientRect();return [Math.round(b.left-17-m.left),Math.round(b.bottom+4-m.top)]})()`), [0, 0])
check('divider height is 1 like the original', menu.dividers.every(divider => Math.round(divider.height) === 1), true)
await shot('menu-open')
await step('menu-dismiss')
await read(`(()=>{const overlay=document.querySelector('.conversation-context-menu-overlay');if(!overlay)return false;overlay.click();return true})()`)
await pause(200)
check('overlay click closes the menu', await read('!!document.querySelector(".conversation-context-menu")'), false)
await step('pin-and-unpin')
await read(openMenu); await wait(menuSource, 'menu for pin')
await read(clickItemByLabel('置顶')); await pause(400)
check('pin adds the original pinned-item class', await read(`document.querySelector('[data-session-id]').classList.contains('conversation-pinned-item')`), true)
check('pin shows a persistent pin badge', await read(`!!document.querySelector('[data-session-id] .sidebar__item-pin')`), true)
check('pin persists to the session store', await read(`window.ycode.session.list().then(list=>list.some(item=>item.pinnedAt!==undefined))`), true)
await read(openMenu); await wait(menuSource, 'menu after pin')
check('pinned row offers 取消置顶 first', menu.items.map(item => item.label).length > 0 && (await read(`document.querySelector('.cb-context-menu-item .cb-context-menu-item-label').textContent`)) === '取消置顶', true)
await read(clickItemByLabel('取消置顶')); await pause(400)
check('unpin clears the pinned marker', await read(`document.querySelector('[data-session-id]').classList.contains('conversation-pinned-item')`), false)
check('unpin persists', await read(`window.ycode.session.list().then(list=>list.every(item=>item.pinnedAt===undefined))`), true)
await step('rename')
await read(openMenu); await wait(menuSource, 'menu for rename')
await read(clickItemByLabel('重命名')); await pause(200)
check('menu exposes an inline rename input', await read(`!!document.querySelector('[data-session-id] .sidebar__item-rename')`), true)
await read(`(()=>{const input=document.querySelector('.sidebar__item-rename');const setter=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set;setter.call(input,'Menu Renamed');input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new KeyboardEvent('keydown',{bubbles:true,key:'Enter'}));return true})()`)
await pause(400)
check('rename input closes after Enter', await read('!!document.querySelector(".sidebar__item-rename")'), false)
check('rename updates the row title', await read(`document.querySelector('[data-session-id] .sidebar__item-title').textContent`), 'Menu Renamed')
check('rename persists to the session store', await read(`window.ycode.session.list().then(list=>list.some(item=>item.title==='Menu Renamed'))`), true)
await step('open-folder')
const expectedCwd = await read(`window.ycode.session.list().then(list=>list[0].cwd)`)
await read(openMenu); await wait(menuSource, 'menu for open folder')
await read(clickItemByLabel('打开文件夹')); await pause(400)
check('open folder targets the real session cwd', openedFolder ? path.resolve(openedFolder) : null, path.resolve(expectedCwd))
await step('open-window')
const windowCount = BrowserWindow.getAllWindows().length
await read(openMenu); await wait(menuSource, 'menu for open window')
await read(clickItemByLabel('在独立窗口中打开')); await pause(1500)
const windows = BrowserWindow.getAllWindows()
const extraWindow = windows.find(window => window !== main)
check('open window creates exactly one extra window', windows.length, windowCount + 1)
check('open window loads the task query for that session', extraWindow ? await extraWindow.webContents.executeJavaScript('location.search').catch(() => null) : null, '?window=task&taskId=' + (await read(`window.ycode.session.list().then(list=>list[0].id)`)))
for (const window of windows) if (window !== main) window.destroy()
await pause(200)
await step('archive')
await read(openMenu); await wait(menuSource, 'menu for archive')
await read(clickItemByLabel('归档')); await pause(500)
const visibleAfterArchive = await read(`document.querySelectorAll('[data-session-id]').length`)
check('archive hides exactly one row', visibleAfterArchive, 1)
check('archive keeps both sessions in the archived listing', await read(`window.ycode.session.listArchived().then(list=>list.length)`), 2)
check('archived session keeps its archivedAt marker', await read(`window.ycode.session.listArchived().then(list=>list.filter(item=>item.archivedAt!==undefined).length)`), 1)
await step('delete')
const remainingId = await read(`window.ycode.session.list().then(list=>list[0].id)`)
await read(openMenu); await wait(menuSource, 'menu for delete')
await read(clickItemByLabel('删除任务')); await pause(500)
check('delete removes that session from the store', await read(`window.ycode.session.listArchived().then(list=>list.some(item=>item.id===${JSON.stringify(remainingId)}))`), false)
check('delete leaves only the archived session', await read(`window.ycode.session.listArchived().then(list=>list.length)`), 1)
check('no renderer error logs', rendererErrors, [])
const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL',
scope: 'Real isolated YCode App/preload/IPC/native filesystem. No upstream calls. Original expectations come from fixed REA archive ev_f5e9d71a676c453daab40928acfb33aeabae68ba57ffda342380cead7f1a1fe2.',
sourceEvidence: ['ev_f5e9d71a676c453daab40928acfb33aeabae68ba57ffda342380cead7f1a1fe2'], snapshots, checks, rendererErrors }
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
console.log('TASK_MENU_' + report.result + ' ' + directory)
for (const failure of checks.filter(item => !item.pass)) console.log('TASK_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); if (main) await shot('failure').catch(() => undefined); app.exit(1)
})
}