// 真实隔离 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_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('context-menu-same-as-more') const contextClicked = await read(`(()=>{const rows=[...document.querySelectorAll('[data-session-id]')];const row=rows[1];if(!row)return null;const ids=rows.map(item=>item.dataset.sessionId);const selected=document.querySelector('[data-selected="true"]')?.dataset.sessionId;row.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true,cancelable:true,button:2,clientX:220,clientY:420}));return {ids,selected}})()`) const contextMenu = await wait(menuSource, 'task context menu from row right-click') check('right-click opens the same implemented menu', contextMenu.items.map(item => item.label), snapshots.menu.items.map(item => item.label)) check('right-click opens the menu for the row under the pointer', await read(`document.querySelector('[data-session-id].sidebar__item--menu-open')?.dataset.sessionId`), contextClicked.ids[1]) check('right-click does not switch the active conversation', await read(`document.querySelector('[data-selected="true"]')?.dataset.sessionId`), contextClicked.selected) check('right-click anchors the menu at the pointer', [Math.round(contextMenu.rect[0]), Math.round(contextMenu.rect[1])], [220, 420]) await read(`document.querySelector('.conversation-context-menu-overlay').click()`) await wait(`!document.querySelector('.conversation-context-menu')`, 'right-click menu closes') 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-confirm') await read(openMenu); await wait(menuSource, 'menu for archive') await read(clickItemByLabel('归档')) await wait(`!!document.querySelector('.task-confirm-dialog[data-task-confirm-kind="archive"]')`, 'archive confirmation') const archiveConfirm = await read(`(()=>{const dialog=document.querySelector('.task-confirm-dialog');const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]};const style=getComputedStyle(dialog);return {box:box(dialog),title:dialog.querySelector('.wb-modal__title')?.textContent,desc:dialog.querySelector('.task-confirm-dialog__description')?.textContent,cancel:dialog.querySelector('.task-confirm-dialog__cancel')?.textContent,confirm:dialog.querySelector('.task-confirm-dialog__confirm')?.textContent,padding:style.padding,borderRadius:style.borderRadius}})()`) check('archive opens a confirmation before changing the task', archiveConfirm.title, '归档任务') check('archive confirmation points to the in-product archive entry', archiveConfirm.desc, '确认将该任务归档吗?归档后可在侧栏「已归档」中查看并恢复。') check('archive confirmation does not reintroduce removed data management', archiveConfirm.desc.includes('数据管理'), false) check('archive confirmation actions use the recovered labels', [archiveConfirm.cancel, archiveConfirm.confirm], ['取消', '确认归档']) check('archive confirmation uses the recovered 480px shell', [Math.round(archiveConfirm.box[2]), archiveConfirm.padding, archiveConfirm.borderRadius], [480, '24px', '24px']) await shot('archive-confirm') await read(`document.querySelector('.task-confirm-dialog__cancel').click()`) await wait(`!document.querySelector('.task-confirm-dialog')`, 'archive cancellation closes') check('cancelling archive keeps the task visible', await read(`document.querySelectorAll('[data-session-id]').length`), 2) await read(openMenu); await wait(menuSource, 'menu for archive confirm') await read(clickItemByLabel('归档')) await wait(`!!document.querySelector('.task-confirm-dialog__confirm')`, 'archive confirmation confirm') await read(`document.querySelector('.task-confirm-dialog__confirm').click()`) await wait(`document.querySelectorAll('[data-session-id]').length===1`, 'archive completes after confirmation') 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-confirm') 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 wait(`!!document.querySelector('.task-confirm-dialog[data-task-confirm-kind="delete"]')`, 'delete confirmation') const deleteConfirm = await read(`(()=>{const dialog=document.querySelector('.task-confirm-dialog');return {title:dialog.querySelector('.wb-modal__title')?.textContent,desc:dialog.querySelector('.task-confirm-dialog__description')?.textContent,cancel:dialog.querySelector('.task-confirm-dialog__cancel')?.textContent,confirm:dialog.querySelector('.task-confirm-dialog__confirm')?.textContent,running:dialog.getAttribute('data-task-confirm-running')}})()`) check('delete uses the idle confirmation title', deleteConfirm.title, '删除任务?') check('delete uses the idle confirmation description', deleteConfirm.desc, '删除后,这些任务将从你的所有设备中移除,且无法恢复。') check('delete confirmation actions use the recovered idle labels', [deleteConfirm.cancel, deleteConfirm.confirm], ['取消', '确认']) check('idle delete is not marked as running', deleteConfirm.running, null) await shot('delete-confirm') await read(`document.querySelector('.task-confirm-dialog__confirm').click()`) await wait(`window.ycode.session.listArchived().then(list=>!list.some(item=>item.id===${JSON.stringify(remainingId)}))`, 'delete completes after confirmation') 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) }) }