docs(B01): report confirmed mode chip and task button styling gaps
This commit is contained in:
1 parent
eaf0ed293a
commit
ddd677bae3
9 files changed
+548
No files matched your search
@@ -0,0 +1,222 @@
|
||||
// 真实隔离 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'),
|
||||
[__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, signal) => {
|
||||
clearTimeout(timer)
|
||||
await fs.writeFile(path.join(directory, 'process.log'), output)
|
||||
console.log(JSON.stringify({ directory, exitCode: code, signal }))
|
||||
if (code !== 0) console.error(output)
|
||||
process.exitCode = code === 0 ? 0 : 1
|
||||
})
|
||||
})()
|
||||
} 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-popconfirm [data-task-confirm-kind="archive"]')`, 'archive confirmation')
|
||||
await wait(`Number(getComputedStyle(document.querySelector(".task-popconfirm")).opacity)>=0.99`, "stable popconfirm");
|
||||
await pause(400);
|
||||
snapshots.archiveButtons = await read("(()=>{const p=s=>{const e=document.querySelector(s),c=getComputedStyle(e);return {text:e.textContent,background:c.backgroundColor,color:c.color,border:c.border,borderRadius:c.borderRadius,minWidth:c.minWidth,padding:c.padding,height:c.height}};return {cancel:p('.task-popconfirm__cancel'),confirm:p('.task-popconfirm__confirm')}})()");
|
||||
const archiveConfirm = await read(`(()=>{const dialog=document.querySelector('.task-popconfirm');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('.task-popconfirm__title')?.textContent,desc:dialog.querySelector('.task-popconfirm__description')?.textContent,cancel:dialog.querySelector('.task-popconfirm__cancel')?.textContent,confirm:dialog.querySelector('.task-popconfirm__confirm')?.textContent,padding:style.padding,borderRadius:style.borderRadius}})()`)
|
||||
check('archive opens a confirmation before changing the task', archiveConfirm.title, '归档任务')
|
||||
check('archive restores the documented original destination', archiveConfirm.desc, '确认将该任务归档吗?归档后可在“系统设置-数据管理”中查看。')
|
||||
check('archive explains the unavailable destination and working fallback', await read(`document.querySelector('.task-popconfirm__compatibility').textContent.includes('侧栏“已归档”')`), true)
|
||||
check('archive confirmation actions use the recovered labels', [archiveConfirm.cancel, archiveConfirm.confirm], ['取消', '确认归档'])
|
||||
check('archive confirmation uses the plan-defined 240px popconfirm', [Math.round(archiveConfirm.box[2]), archiveConfirm.padding, archiveConfirm.borderRadius], [240, '16px', '12px'])
|
||||
await shot('archive-confirm')
|
||||
await read(`document.querySelector('.task-popconfirm__cancel').click()`)
|
||||
await wait(`!document.querySelector('.task-popconfirm')`, '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-popconfirm__confirm')`, 'archive confirmation confirm')
|
||||
await read(`document.querySelector('.task-popconfirm__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-popconfirm [data-task-confirm-kind="delete"]')`, 'delete confirmation')
|
||||
await wait(`Number(getComputedStyle(document.querySelector(".task-popconfirm")).opacity)>=0.99`, "stable popconfirm");
|
||||
await pause(400);
|
||||
snapshots.deleteButtons = await read("(()=>{const p=s=>{const e=document.querySelector(s),c=getComputedStyle(e);return {text:e.textContent,background:c.backgroundColor,color:c.color,border:c.border,borderRadius:c.borderRadius,minWidth:c.minWidth,padding:c.padding,height:c.height}};return {cancel:p('.task-popconfirm__cancel'),confirm:p('.task-popconfirm__confirm')}})()");
|
||||
const deleteConfirm = await read(`(()=>{const dialog=document.querySelector('.task-popconfirm');return {title:dialog.querySelector('.task-popconfirm__title')?.textContent,desc:dialog.querySelector('.task-popconfirm__description')?.textContent,cancel:dialog.querySelector('.task-popconfirm__cancel')?.textContent,confirm:dialog.querySelector('.task-popconfirm__confirm')?.textContent,running:dialog.querySelector('[data-task-confirm-kind]').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-popconfirm__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)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user