380 lines
37 KiB
JavaScript
380 lines
37 KiB
JavaScript
// 真实隔离 YCode App + 真实 preload/IPC/渲染进程,验证用户消息就地编辑(TODO 03)。
|
||
// 原版期望值来自:ev_245ea1740cde21869500348e47cf9663a1ea72943ccfe79c163a6838a153bfd3(打开/取消编辑)、
|
||
// ev_5dd78e06e75b3a29b656d328c2efc2a820ea197e8204a39840207188a6496a0f(保持编辑器打开)、
|
||
// ev_ee345b160e5f520ed866c1059b7c72e77b34e49bf1ce407cd266ea886aa86405(编辑态布局/计算样式快照)、
|
||
// src-CyXHA0IC.css(SHA-256 a5e26b537bb50323aefbf25abddad52e288dd3868598be8d255d70886fe77cfe)。
|
||
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/edit-'))
|
||
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 crypto = require('node:crypto')
|
||
const now = Date.now()
|
||
// 悬停时间三档夹具:当天(既有消息)、同年异日、跨年。
|
||
const currentYear = new Date(now).getFullYear()
|
||
const todayKey = new Date(now).toDateString()
|
||
const sameYearDay = [2, 3, 4, 5].map((day) => new Date(currentYear, 0, day, 9, 5).getTime())
|
||
.find((value) => new Date(value).toDateString() !== todayKey)
|
||
const otherYear = new Date(currentYear - 2, 11, 31, 9, 5).getTime()
|
||
const crossYearUserId = crypto.randomUUID()
|
||
const sameYearUserId = crypto.randomUUID()
|
||
const firstUserId = crypto.randomUUID()
|
||
const secondUserId = crypto.randomUUID()
|
||
// 产物回退夹具:目标消息之后的助手回合写入了一个文件,编辑提交后该文件应回退到旧内容。
|
||
const editedFilePath = path.join(cwd, 'edited-artifact.txt')
|
||
const editedFileBefore = '旧内容:尚未编辑'
|
||
const editedFileAfter = '新内容:编辑后写入'
|
||
await fs.writeFile(editedFilePath, editedFileAfter, 'utf8')
|
||
const sessions = [{ id: crypto.randomUUID(), title: 'Edit Live', cwd, createdAt: now, updatedAt: now + 1, messages: [
|
||
{ id: crossYearUserId, role: 'user', content: '跨年历史消息', createdAt: otherYear, status: 'done', requestId: 'req-cross-year' },
|
||
{ id: crypto.randomUUID(), role: 'assistant', content: '跨年回复。', createdAt: otherYear + 1000, status: 'done', requestId: 'req-cross-year' },
|
||
{ id: sameYearUserId, role: 'user', content: '同年历史消息', createdAt: sameYearDay, status: 'done', requestId: 'req-same-year' },
|
||
{ id: crypto.randomUUID(), role: 'assistant', content: '同年回复。', createdAt: sameYearDay + 1000, status: 'done', requestId: 'req-same-year' },
|
||
{ id: firstUserId, role: 'user', content: '第一条历史消息', createdAt: now - 4000, status: 'done', requestId: 'req-first' },
|
||
{ id: crypto.randomUUID(), role: 'assistant', content: '收到第一条。', createdAt: now - 3000, status: 'done', requestId: 'req-first' },
|
||
{ id: secondUserId, role: 'user', content: 'workbuddy是否有目标模式', createdAt: now - 2000, status: 'done', requestId: 'req-second' },
|
||
{ id: crypto.randomUUID(), role: 'assistant', content: '本地文档里没有直接命中。', createdAt: now - 1000, status: 'done', requestId: 'req-second', tools: [
|
||
{ id: 'tool-write-1', name: 'write_file', status: 'ok', args: { path: editedFilePath }, result: { path: editedFilePath, oldContent: editedFileBefore, newContent: editedFileAfter } }
|
||
] }
|
||
] }, { id: crypto.randomUUID(), title: 'Edit Other', cwd, createdAt: now - 9000, updatedAt: now - 8000, messages: [
|
||
{ id: crypto.randomUUID(), role: 'user', content: '另一个会话', createdAt: now - 9000, status: 'done' }
|
||
] }]
|
||
for (const message of sessions[0].messages) if (message.role === 'user') message.nativeEntryId = 'qa-native-' + message.id
|
||
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions }))
|
||
await fs.writeFile(path.join(directory, 'expected.json'), JSON.stringify({ crossYearUserId, sameYearUserId, firstUserId, secondUserId, editedFilePath, editedFileBefore, editedFileAfter,
|
||
sameYearDay, otherYear, userTimes: [otherYear, sameYearDay, now - 4000, now - 2000] }))
|
||
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('USER_MESSAGE_EDIT_')) process.stdout.write(bytes) })
|
||
child.stderr.on('data', bytes => { output += bytes })
|
||
const timer = setTimeout(() => child.kill(), 180000)
|
||
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, ipcMain } = require('electron')
|
||
app.disableHardwareAcceleration()
|
||
const directory = process.env.YCODE_QA_DIRECTORY
|
||
assert.ok(path.relative(path.resolve('.tmp'), directory).startsWith('edit-'))
|
||
const expected = JSON.parse(require('node:fs').readFileSync(path.join(directory, 'expected.json'), 'utf8'))
|
||
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 sentPayloads = []
|
||
const revertCalls = []
|
||
let sendDelayMs = 0
|
||
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 lastFrame = `[...document.querySelectorAll('.cr-frame--right')].pop()`
|
||
const timesSource = `[...document.querySelectorAll('.cr-frame--right')].map(frame => frame.querySelector('.cr-toolbar__time')?.textContent ?? null)`
|
||
// 与实现相同的三档规则(当天 HH:mm;同年 M月D日 HH:mm;跨年 YYYY年M月D日 HH:mm),独立实现用于校验渲染结果。
|
||
function expectedTimeText(time) {
|
||
const date = new Date(time)
|
||
const now = new Date()
|
||
const hoursMinutes = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||
if (date.toDateString() === now.toDateString()) return hoursMinutes
|
||
const year = date.getFullYear() === now.getFullYear() ? '' : `${date.getFullYear()}年`
|
||
return `${year}${date.getMonth() + 1}月${date.getDate()}日 ${hoursMinutes}`
|
||
}
|
||
const toolbarSource = `(()=>{const frame=${lastFrame};const toolbar=frame&&frame.querySelector('.cr-toolbar');if(!toolbar)return null;const style=getComputedStyle(toolbar);const time=toolbar.querySelector('.cr-toolbar__time');const buttons=[...toolbar.querySelectorAll('.cr-icon-button')].map(button=>{const bs=getComputedStyle(button);const box=button.getBoundingClientRect();const icon=button.querySelector('svg');return {action:button.getAttribute('data-action'),label:button.getAttribute('aria-label'),width:box.width,height:box.height,radius:bs.borderRadius,color:bs.color,icon:icon?[icon.getBoundingClientRect().width,icon.getBoundingClientRect().height,icon.getAttribute('viewBox')]:null}});const bubble=frame.querySelector('.cr-self-bubble').getBoundingClientRect();const frameBox=frame.getBoundingClientRect();const toolbarBox=toolbar.getBoundingClientRect();return {messageId:toolbar.getAttribute('data-message-id'),role:toolbar.getAttribute('role'),position:style.position,top:style.top,right:style.right,gap:style.gap,paddingTop:style.paddingTop,opacity:style.opacity,pointerEvents:style.pointerEvents,transition:style.transitionProperty+' '+style.transitionDuration+' '+style.transitionTimingFunction,time:time?{text:time.textContent,fontSize:getComputedStyle(time).fontSize,lineHeight:getComputedStyle(time).lineHeight,color:getComputedStyle(time).color}:null,buttons,buttonBox:[bubble.x,bubble.y,bubble.width,bubble.height],frameBox:[frameBox.x,frameBox.y,frameBox.width,frameBox.height],toolbarBox:[toolbarBox.x,toolbarBox.y,toolbarBox.width,toolbarBox.height]}})()`
|
||
const editorSource = `(()=>{const frame=${lastFrame};const bubble=frame&&frame.querySelector('.cr-self-bubble');const editor=frame&&frame.querySelector('.cr-user-message-editor');if(!bubble||!editor)return null;const s=getComputedStyle(editor);const footer=editor.querySelector('.cr-user-message-editor__footer');const hint=editor.querySelector('.cr-user-message-editor__hint');const hintIcon=editor.querySelector('.cr-user-message-editor__hint-icon');const actions=editor.querySelector('.cr-user-message-editor__actions');const cancel=editor.querySelector('.cr-user-message-editor__cancel');const send=editor.querySelector('.cr-user-message-editor__send');const host=editor.querySelector('.cr-input-editor-host');const editable=editor.querySelector('[data-slate-editor]');const container=editor.querySelector('.cr-input-container');const box=node=>{const rect=node.getBoundingClientRect();return [rect.x,rect.y,rect.width,rect.height]};const style=node=>getComputedStyle(node);return {bubbleClass:bubble.className,bubbleBackground:style(bubble).backgroundColor,bubbleRadius:style(bubble).borderRadius,bubblePadding:style(bubble).padding,display:s.display,width:s.width,footerPaddingTop:footer?style(footer).paddingTop:null,footerGap:footer?style(footer).gap:null,footerJustify:footer?style(footer).justifyContent:null,hintText:hint?hint.textContent:null,hintFont:hint?style(hint).fontSize+'/'+style(hint).lineHeight:null,hintColor:hint?style(hint).color:null,hintGap:hint?style(hint).gap:null,hintIcon:hintIcon?box(hintIcon):null,actionsGap:actions?style(actions).gap:null,cancel:cancel?{box:box(cancel),disabled:cancel.disabled,radius:style(cancel).borderRadius,padding:style(cancel).padding,font:style(cancel).fontSize+'/'+style(cancel).lineHeight,border:style(cancel).borderTopWidth+' '+style(cancel).borderTopColor,color:style(cancel).color,background:style(cancel).backgroundColor}:null,send:send?{box:box(send),disabled:send.disabled,busy:send.getAttribute('aria-busy'),radius:style(send).borderRadius,padding:style(send).padding,font:style(send).fontSize+'/'+style(send).lineHeight,color:style(send).color,background:style(send).backgroundColor,gap:style(send).gap,spinner:!!send.querySelector('svg')}:null,host:host?{box:box(host),minHeight:style(host).minHeight,overflow:style(host).overflowY,padding:style(host).padding}:null,editable:editable?{box:box(editable),fontSize:style(editable).fontSize,lineHeight:style(editable).lineHeight,minHeight:style(editable).minHeight,text:editable.innerText}:null,container:container?{box:box(container),background:style(container).backgroundColor,border:style(container).borderTopWidth,boxShadow:style(container).boxShadow,radius:style(container).borderRadius,paddingTop:style(container).paddingTop,minHeight:style(container).minHeight}:null,error:editor.querySelector('.cr-user-message-editor__error')?editor.querySelector('.cr-user-message-editor__error').textContent:null,toolbarMounted:!!frame.querySelector('.cr-toolbar')}})()`
|
||
const setEditable = text => `(()=>{const editable=document.querySelector('.cr-user-message-editor [data-slate-editor]');if(!editable)return false;editable.focus();editable.innerText=${JSON.stringify(text)};editable.dispatchEvent(new Event('input',{bubbles:true}));return true})()`
|
||
|
||
;(async () => {
|
||
await import('node:url').then(({ pathToFileURL }) => import(pathToFileURL(path.resolve('out/main/index.mjs')).href))
|
||
await app.whenReady()
|
||
// 记录编辑提交负载并返回合成分支:隔离环境没有可用模型,但提交链路必须真实经过 IPC。
|
||
// 回退走真实渲染链路与真实 preload/IPC;主进程侧换成记录器,
|
||
// 真实文件与历史语义由 npm run test:conversation-ipc 覆盖。
|
||
ipcMain.removeHandler('chat:revert')
|
||
ipcMain.handle('chat:revert', async (_event, sessionId, messageId, options) => {
|
||
revertCalls.push({ sessionId, messageId, options })
|
||
return { messages: [] }
|
||
})
|
||
ipcMain.removeHandler('chat:send')
|
||
ipcMain.handle('chat:send', async (_event, payload) => {
|
||
if (sendDelayMs) await pause(sendDelayMs)
|
||
sentPayloads.push(payload)
|
||
const runId = 'edit-run-' + sentPayloads.length
|
||
const userMessage = { id: payload.fromMessageId, nativeEntryId: 'qa-replacement', role: 'user', content: payload.text, createdAt: Date.now(), status: 'done', requestId: runId }
|
||
const assistant = { id: 'edit-assistant-' + sentPayloads.length, role: 'assistant', content: '已重新开始。', createdAt: Date.now(), status: 'done', requestId: runId, tools: [], parts: [] }
|
||
// ChatEvent 的 done 形状:{ runId, type:'done', content, tools }。
|
||
setTimeout(() => { try { main?.webContents.send('chat:event', { sessionId: payload.sessionId, runId, type: 'done', content: assistant.content, tools: [] }) } catch { /* window closed */ } }, 60)
|
||
return { runId, assistantMessageId: assistant.id, userMessage, messages: [userMessage, assistant] }
|
||
})
|
||
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 step('select-session')
|
||
await wait(`!!document.querySelector('[data-session-id]')`, 'sidebar rows')
|
||
await read(`(()=>{const rows=[...document.querySelectorAll('[data-session-id]')];const row=rows.find(item=>item.textContent.includes('Edit Live'));if(!row)return false;row.click();return true})()`)
|
||
await wait(`!!${lastFrame}.querySelector('.cr-toolbar')`, 'user toolbar')
|
||
await pause(300)
|
||
|
||
await step('toolbar-idle')
|
||
main.webContents.sendInputEvent({ type: 'mouseMove', x: 4, y: 4 })
|
||
await pause(200)
|
||
const idle = await wait(toolbarSource, 'toolbar snapshot')
|
||
snapshots.idle = idle
|
||
const userBubbles = await read(`[...document.querySelectorAll('.cr-frame--right')].length`)
|
||
check('fixture renders four user messages', userBubbles, 4)
|
||
check('toolbar keeps the original DOM contract', idle.role === 'toolbar' && typeof idle.messageId === 'string' && idle.messageId.length > 0, true)
|
||
check('toolbar is absolutely positioned with right 0', idle.position === 'absolute' && idle.right === '0px', true)
|
||
// 原版实测(ev_245ea174…):工具条底边与消息 frame 底边重合、右边缘对齐(frame 182.59 / toolbar 182.59、925.98 / 925.98)。
|
||
check('toolbar bottom and right edge align with the message frame like the original', Math.abs((idle.toolbarBox[1] + idle.toolbarBox[3]) - (idle.frameBox[1] + idle.frameBox[3])) <= 1 && Math.abs((idle.toolbarBox[0] + idle.toolbarBox[2]) - (idle.frameBox[0] + idle.frameBox[2])) <= 1, true)
|
||
check('toolbar gap is 8 and padding-top is 4', idle.gap === '8px' && idle.paddingTop === '4px', true)
|
||
check('toolbar fades with the original transition', idle.transition, 'opacity 0.2s ease-in-out')
|
||
check('toolbar is hidden while not hovered', idle.opacity === '0' && idle.pointerEvents === 'none', true)
|
||
check('toolbar time follows the 14/20 tertiary rule', idle.time.fontSize === '14px' && idle.time.lineHeight === '20px' && idle.time.color === 'rgba(0, 0, 0, 0.5)', true)
|
||
check('toolbar time uses HH:mm for today', /^\d{2}:\d{2}$/.test(idle.time.text), true)
|
||
// 原版桌面端回退入口标签来自 rewind.entry.tooltip(ConversationTimelineImpl.revertLabel),
|
||
// 该消息之后存在产物写入,因此弹窗走 artifacts 变体。
|
||
check('toolbar keeps the original button set (copy + revert + edit, no resend)', idle.buttons.length, 3)
|
||
check('button order is copy, revert, edit like the original', idle.buttons.map(item => item.action).join(','), 'copy,revert,edit')
|
||
check('buttons are 24x24 with 14x14 icons and radius 6', idle.buttons.every(item => item.width === 24 && item.height === 24 && item.radius === '6px' && item.icon[0] === 14 && item.icon[1] === 14), true)
|
||
check('copy/edit keep the 16x16 viewBox while revert keeps the recovered 14x14 one', idle.buttons.map(item => item.action + ':' + item.icon[2]).join(','), 'copy:0 0 16 16,revert:0 0 14 14,edit:0 0 16 16')
|
||
check('buttons expose the recovered rewind tooltip', idle.buttons.map(item => item.label).join(','), '复制,将对话及产物回退到此消息前,编辑')
|
||
|
||
await step('toolbar-hover')
|
||
const bubble = idle.buttons[0] && (await read(`(()=>{const box=${lastFrame}.querySelector('.cr-self-bubble').getBoundingClientRect();return {x:Math.round(box.x+box.width/2),y:Math.round(box.y+box.height/2)}})()`))
|
||
main.webContents.sendInputEvent({ type: 'mouseMove', x: bubble.x, y: bubble.y })
|
||
await pause(400)
|
||
check('hovering the message reveals the toolbar', await read(`getComputedStyle(${lastFrame}.querySelector('.cr-toolbar')).opacity`), '1')
|
||
check('hovered toolbar accepts pointer events', await read(`getComputedStyle(${lastFrame}.querySelector('.cr-toolbar')).pointerEvents`), 'auto')
|
||
await shot('toolbar-hover')
|
||
|
||
await step('hover-time-tiers')
|
||
const times = await read(timesSource)
|
||
snapshots.times = times
|
||
check('every user message owns a hover time', times.length, expected.userTimes.length)
|
||
check('hover time follows the confirmed three-tier format', times, expected.userTimes.map(expectedTimeText))
|
||
check('same-year non-today tier renders M月D日 HH:mm', times[1], expectedTimeText(expected.sameYearDay))
|
||
check('cross-year tier renders YYYY年M月D日 HH:mm', times[0], expectedTimeText(expected.otherYear))
|
||
check('today tier renders HH:mm', times[3], expectedTimeText(expected.userTimes[3]))
|
||
await read(`(()=>{const rows=[...document.querySelectorAll('[data-session-id]')];const row=rows.find(item=>item.textContent.includes('Edit Other'));if(!row)return false;row.click();return true})()`)
|
||
await pause(500)
|
||
check('switching sessions shows the other session time only', await read(`document.querySelectorAll('.cr-frame--right .cr-toolbar__time').length`), 1)
|
||
await read(`document.querySelector('.cr-frame--right [data-action="edit"]').click()`)
|
||
await wait(`!!document.querySelector('.chat-pane [role="alert"]')`, 'legacy edit preflight error')
|
||
check('legacy messages fail preflight before opening an editor', await read(`!!document.querySelector('.cr-user-message-editor')`), false)
|
||
check('legacy preflight explains the missing branch', await read(`document.querySelector('.chat-pane [role="alert"]').textContent.includes('原生分支')`), true)
|
||
await read(`(()=>{const rows=[...document.querySelectorAll('[data-session-id]')];const row=rows.find(item=>item.textContent.includes('Edit Live'));if(!row)return false;row.click();return true})()`)
|
||
await pause(600)
|
||
check('history reload keeps the three-tier hover times', await read(timesSource), expected.userTimes.map(expectedTimeText))
|
||
|
||
await step('open-editor')
|
||
await read(`(()=>{const button=${lastFrame}.querySelector('.cr-toolbar .cr-icon-button[data-action="edit"]');if(!button)return false;button.click();return true})()`)
|
||
const editor = await wait(editorSource, 'editor snapshot')
|
||
snapshots.editor = editor
|
||
check('editing unmounts the hover toolbar like the original', editor.toolbarMounted, false)
|
||
check('bubble gets the editing class, surface and 8x12 padding', editor.bubbleClass.includes('cr-self-bubble--editing') && editor.bubbleBackground === 'rgb(242, 242, 242)' && editor.bubblePadding === '8px 12px', true)
|
||
check('editor container is transparent with no border or shadow', editor.container.background === 'rgba(0, 0, 0, 0)' && editor.container.border === '0px' && editor.container.boxShadow === 'none' && editor.container.radius === '0px', true)
|
||
check('editor container keeps min-height 50 with no top padding', editor.container.minHeight === '50px' && editor.container.paddingTop === '0px', true)
|
||
check('editor host keeps the 64px min-height without padding', editor.host.minHeight === '64px' && editor.host.padding === '0px', true)
|
||
check('editable uses 15px/1.75 like the original', editor.editable.fontSize === '15px' && editor.editable.lineHeight === '26.25px', true)
|
||
check('editable keeps the original min-height 50px', editor.editable.minHeight === '50px', true)
|
||
check('editable is prefilled with the message text', editor.editable.text, 'workbuddy是否有目标模式')
|
||
check('footer uses 12px top padding, 12px gap and space-between', editor.footerPaddingTop === '12px' && editor.footerGap === '12px' && editor.footerJustify === 'space-between', true)
|
||
check('hint keeps the recovered copy', editor.hintText, '编辑后将从此处重新开始对话,产物也将回退至此信息前版本')
|
||
check('hint uses 13/18 with 4px gap and 14x14 icon', editor.hintFont === '13px/18px' && editor.hintGap === '4px' && editor.hintIcon[2] === 14 && editor.hintIcon[3] === 14, true)
|
||
check('actions gap is 8', editor.actionsGap, '8px')
|
||
check('cancel is 62x32 with 8px radius and 5px 16px padding', Math.round(editor.cancel.box[2]) === 62 && Math.round(editor.cancel.box[3]) === 32 && editor.cancel.radius === '8px' && editor.cancel.padding === '5px 16px', true)
|
||
check('cancel uses the default border and secondary text', editor.cancel.border === '1px rgb(235, 235, 235)' && editor.cancel.color === 'rgba(0, 0, 0, 0.7)', true)
|
||
check('send is 60x30 on the contrast surface', Math.round(editor.send.box[2]) === 60 && Math.round(editor.send.box[3]) === 30 && editor.send.background === 'rgb(26, 26, 26)' && editor.send.color === 'rgb(255, 255, 255)', true)
|
||
check('send button is not disabled when content is unchanged', editor.send.disabled, false)
|
||
check('no error is shown while content is valid', editor.error, null)
|
||
await shot('editor-open')
|
||
|
||
await step('cancel-restores')
|
||
await read(setEditable('改动但不提交'))
|
||
await pause(120)
|
||
check('edited text is reflected in the field', await read(`document.querySelector('.cr-user-message-editor [data-slate-editor]').innerText`), '改动但不提交')
|
||
await read(`document.querySelector('.cr-user-message-editor__cancel').click()`)
|
||
await pause(250)
|
||
check('cancel closes the editor', await read(`!!document.querySelector('.cr-user-message-editor')`), false)
|
||
check('cancel restores the original bubble text', await read(`[...document.querySelectorAll('.cr-frame--right')].pop().querySelector('.cr-self-bubble').textContent`), 'workbuddy是否有目标模式')
|
||
|
||
await step('outside-mousedown-keeps-changes')
|
||
await read(`(()=>{const bubble=[...document.querySelectorAll('.cr-frame--right')].pop();bubble.scrollIntoView({block:'center'});bubble.dispatchEvent(new MouseEvent('mousemove',{bubbles:true}));bubble.dispatchEvent(new MouseEvent('mouseover',{bubbles:true}));return true})()`)
|
||
main.webContents.sendInputEvent({ type: 'mouseMove', x: bubble.x, y: bubble.y })
|
||
await pause(350)
|
||
await read(`${lastFrame}.querySelector('.cr-toolbar .cr-icon-button[data-action="edit"]').click()`)
|
||
await wait(`!!document.querySelector('.cr-user-message-editor')`, 'editor reopen')
|
||
await read(setEditable('有改动时点击外部'))
|
||
await pause(120)
|
||
await read(`document.body.dispatchEvent(new MouseEvent('mousedown',{bubbles:true}))`)
|
||
await pause(200)
|
||
check('mousedown outside keeps the editor when there are changes', await read(`!!document.querySelector('.cr-user-message-editor')`), true)
|
||
|
||
await step('empty-validation')
|
||
await read(setEditable(' '))
|
||
await pause(150)
|
||
const emptyEditor = await read(editorSource)
|
||
check('blank content raises the recovered empty error', emptyEditor.error, '消息内容不能为空')
|
||
check('send is disabled while the content is empty', emptyEditor.send.disabled, true)
|
||
await read(`document.querySelector('.cr-user-message-editor__send').click()`)
|
||
await pause(200)
|
||
check('blocked submit never reaches chat:send', sentPayloads.filter(item => item.action === 'edit').length, 0)
|
||
await read(setEditable('workbuddy是否有目标模式'))
|
||
await pause(120)
|
||
check('error clears once content is valid again', await read(`!!document.querySelector('.cr-user-message-editor__error')`), false)
|
||
|
||
await step('submit-edit')
|
||
sendDelayMs = 700
|
||
await read(setEditable('workbuddy 有目标模式吗'))
|
||
await pause(120)
|
||
await read(`document.querySelector('.cr-user-message-editor__send').click()`)
|
||
await wait(`document.querySelector('.dialog[role="dialog"]')?.getAttribute('aria-label') === '是否回退产物历史版本?'`, 'edit artifact choice')
|
||
check('artifact edits ask before sending', sentPayloads.length, 0)
|
||
await read(`document.querySelector('.dialog[role="dialog"] .dialog__actions button').click()`)
|
||
await wait(`!document.querySelector('.dialog[role="dialog"]')`, 'cancel edit artifact choice')
|
||
check('cancelling the artifact choice retains the edited draft', await read(`document.querySelector('.cr-user-message-editor [data-slate-editor]').innerText`), 'workbuddy 有目标模式吗')
|
||
check('cancelling the artifact choice does not send', sentPayloads.length, 0)
|
||
await read(`document.querySelector('.cr-user-message-editor__send').click()`)
|
||
await wait(`!!document.querySelector('.dialog[role="dialog"]')`, 'edit artifact choice reopened')
|
||
await read(`(()=>{const button=[...document.querySelectorAll('.dialog__actions button')].find(item=>item.textContent==='不回退');button.click()})()`)
|
||
await pause(250)
|
||
const submitting = await read(editorSource)
|
||
snapshots.submitting = submitting
|
||
check('submitting disables cancel and send with aria-busy', submitting.cancel.disabled === true && submitting.send.disabled === true && submitting.send.busy === 'true', true)
|
||
check('submitting shows the spinner inside the send button', submitting.send.spinner, true)
|
||
// 原版 showDefaultRevert/showDefaultEdit = !isSessionActive:会话活动期间回退与编辑入口都不渲染。
|
||
const busyEntries = await read(`[...document.querySelectorAll('.cr-frame--right')].map(frame => ({messageId: frame.querySelector('.cr-toolbar')?.getAttribute('data-message-id') ?? null, actions: [...(frame.querySelector('.cr-toolbar')?.querySelectorAll('.cr-icon-button') ?? [])].map(button => button.getAttribute('data-action'))})).filter(item => item.messageId)`)
|
||
check('active session hides rewind and edit entries like showDefaultRevert/showDefaultEdit', busyEntries.every(item => item.actions.join(',') === 'copy'), true)
|
||
for (let index = 0; index < 60 && sentPayloads.length === 0; index++) await pause(50)
|
||
const editPayload = sentPayloads.find(item => item.action === 'edit')
|
||
check('edit submit reaches chat:send through IPC', !!editPayload, true)
|
||
check('edit payload carries action=edit with the original message id', editPayload.action === 'edit' && editPayload.fromMessageId === expected.secondUserId, true)
|
||
check('edit payload sends the modified text', editPayload.text, 'workbuddy 有目标模式吗')
|
||
check('edit payload records the explicit choice to preserve current artifacts', editPayload.rollbackArtifacts, false)
|
||
await wait(`!document.querySelector('.cr-user-message-editor')`, 'editor closed after submit')
|
||
check('submit closes the editor', await read(`!!document.querySelector('.cr-user-message-editor')`), false)
|
||
// 产物回退由真实主进程语义覆盖:npm run test:conversation-ipc 的 testEditArtifactRollback
|
||
// (本脚本把 chat:send 换成 stub,因此不在此处重复断言文件内容)。
|
||
await shot('editor-submitted')
|
||
|
||
await step('enter-submits')
|
||
sendDelayMs = 0
|
||
await read(`(()=>{const bubble=[...document.querySelectorAll('.cr-frame--right')].pop();bubble.dispatchEvent(new MouseEvent('mousemove',{bubbles:true}));return true})()`)
|
||
main.webContents.sendInputEvent({ type: 'mouseMove', x: bubble.x, y: bubble.y })
|
||
await pause(300)
|
||
await read(`(()=>{const button=${lastFrame}.querySelector('.cr-toolbar .cr-icon-button[data-action="edit"]');if(!button)return false;button.click();return true})()`)
|
||
await wait(`!!document.querySelector('.cr-user-message-editor')`, 'editor for enter test')
|
||
await read(setEditable('回车提交'))
|
||
await pause(120)
|
||
await read(`document.querySelector('.cr-user-message-editor [data-slate-editor]').dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true}))`)
|
||
for (let index = 0; index < 60 && sentPayloads.filter(item => item.action === 'edit').length < 2; index++) await pause(50)
|
||
check('Enter submits the edited message like the original onEnterSend', sentPayloads.filter(item => item.action === 'edit').length, 2)
|
||
check('second edit payload keeps the typed text', sentPayloads.filter(item => item.action === 'edit')[1].text, '回车提交')
|
||
await wait(`!document.querySelector('.cr-user-message-editor')`, 'editor closed after enter submit')
|
||
|
||
// 编辑提交会以桩返回替换内存消息;重新选中会话以从真实 sessions 存储恢复夹具,
|
||
// 再验证回退入口(resolveRevertEntryVisible)、弹窗三态与确认链路。
|
||
await step('rewind-reload-fixture')
|
||
await read(`window.location.reload()`)
|
||
await wait(`!!document.querySelector('#root')`, 'renderer reloaded')
|
||
await pause(1500)
|
||
await wait(`!!document.querySelector('[data-session-id]')`, 'sidebar rows after reload')
|
||
await read(`(()=>{const rows=[...document.querySelectorAll('[data-session-id]')];const row=rows.find(item=>item.textContent.includes('Edit Live'));if(!row)return false;row.click();return true})()`)
|
||
await wait(`document.querySelectorAll('.cr-frame--right').length === 4`, 'fixture restored')
|
||
|
||
await step('rewind-entry')
|
||
const rewindEntries = await read(`[...document.querySelectorAll('.cr-frame--right')].map(frame => ({messageId: frame.querySelector('.cr-toolbar')?.getAttribute('data-message-id') ?? null, actions: [...(frame.querySelector('.cr-toolbar')?.querySelectorAll('.cr-icon-button') ?? [])].map(button => button.getAttribute('data-action'))}))`)
|
||
snapshots.rewindEntries = rewindEntries
|
||
const entryOf = id => rewindEntries.find(item => item.messageId === id)?.actions ?? null
|
||
check('first user message hides the rewind entry like resolveRevertEntryVisible', entryOf(expected.crossYearUserId), ['copy', 'edit'])
|
||
check('later user messages render copy + rewind + edit', [expected.sameYearUserId, expected.firstUserId, expected.secondUserId].map(entryOf).map(actions => actions?.join(',')).join('|'),
|
||
'copy,revert,edit|copy,revert,edit|copy,revert,edit')
|
||
|
||
const openRewind = async () => {
|
||
await read(`(()=>{const frame=[...document.querySelectorAll('.cr-frame--right')].find(item => item.querySelector('.cr-toolbar')?.getAttribute('data-message-id') === ${JSON.stringify(expected.secondUserId)});const button=frame?.querySelector('.cr-toolbar .cr-icon-button[data-action="revert"]');if(!button)return false;button.click();return true})()`)
|
||
await wait(`!!document.querySelector('.dialog[role="dialog"]')`, 'rewind dialog')
|
||
}
|
||
await step('rewind-dialog-cancel')
|
||
await openRewind()
|
||
const dialog = await read(`(()=>{const dialog=document.querySelector('.dialog[role="dialog"]');if(!dialog)return null;const rows=[...dialog.querySelectorAll('.rewind-artifacts__row')].map(row=>({name:row.querySelector('.rewind-artifacts__name')?.textContent??null,title:row.querySelector('.rewind-artifacts__name')?.getAttribute('title')??null,status:row.querySelector('.rewind-artifacts__status')?.textContent??null}));const actions=[...dialog.querySelectorAll('.dialog__actions button')];return {title:dialog.querySelector('.dialog__title')?.textContent??null,description:dialog.querySelector('p')?.textContent??null,aria:dialog.getAttribute('aria-label'),labels:actions.map(button=>button.textContent),rows}})()`)
|
||
snapshots.rewindDialog = dialog
|
||
check('rewind dialog uses the recovered artifacts variant title', dialog.title, '将对话及以下产物版本回退到此消息前?')
|
||
check('rewind dialog carries the recovered description', dialog.description, '回退暂不支持部分类型文件')
|
||
check('rewind dialog offers cancel, keep-artifacts and rewind', dialog.labels.join(','), '取消,不回退,回退')
|
||
check('rewind dialog lists the pending artifact with its status', dialog.rows.length === 1 && dialog.rows[0].name === 'edited-artifact.txt' && dialog.rows[0].status === '回退至历史版本' && dialog.rows[0].title === expected.editedFilePath, true)
|
||
await shot('rewind-dialog')
|
||
await read(`document.querySelector('.dialog[role="dialog"] .dialog__actions button').click()`)
|
||
await wait(`!document.querySelector('.dialog[role="dialog"]')`, 'rewind dialog closed')
|
||
check('cancel keeps the conversation and never reaches chat:revert', revertCalls.length, 0)
|
||
|
||
await step('rewind-confirm')
|
||
await openRewind()
|
||
await read(`(()=>{const actions=[...document.querySelectorAll('.dialog[role="dialog"] .dialog__actions button')];actions[actions.length-1].click();return true})()`)
|
||
for (let index = 0; index < 60 && revertCalls.length === 0; index++) await pause(50)
|
||
check('confirm reaches chat:revert with the session and message id', revertCalls.length === 1 && revertCalls[0].messageId === expected.secondUserId && typeof revertCalls[0].sessionId === 'string', true)
|
||
await wait(`!document.querySelector('.dialog[role="dialog"]')`, 'rewind dialog closed after confirm')
|
||
check('rewind replaces the conversation with the rewound prefix', await read(`document.querySelectorAll('.cr-frame--right').length`), 0)
|
||
await read(`window.location.reload()`)
|
||
await wait(`!!document.querySelector('[data-session-id]')`, 'reload for conversation-only rewind')
|
||
await read(`(()=>{const row=[...document.querySelectorAll('[data-session-id]')].find(item=>item.textContent.includes('Edit Live'));row.click()})()`)
|
||
await wait(`document.querySelectorAll('.cr-frame--right').length === 4`, 'conversation-only fixture')
|
||
await openRewind()
|
||
await read(`(()=>{const button=[...document.querySelectorAll('.dialog__actions button')].find(item=>item.textContent==='不回退');button.click()})()`)
|
||
await wait(`!document.querySelector('.dialog[role="dialog"]')`, 'conversation-only rewind closed')
|
||
check('conversation-only rewind sends the artifact option through preload', revertCalls[1]?.options?.rollbackArtifacts, false)
|
||
check('no renderer error logs', rendererErrors, [])
|
||
|
||
const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL',
|
||
scope: 'Real isolated YCode App/preload/IPC/renderer. chat:send is stubbed in the test main process to record payloads because the sandbox has no model.',
|
||
sourceEvidence: ['ev_245ea1740cde21869500348e47cf9663a1ea72943ccfe79c163a6838a153bfd3', 'ev_5dd78e06e75b3a29b656d328c2efc2a820ea197e8204a39840207188a6496a0f',
|
||
'ev_ee345b160e5f520ed866c1059b7c72e77b34e49bf1ce407cd266ea886aa86405', 'src-CyXHA0IC.css a5e26b537bb50323aefbf25abddad52e288dd3868598be8d255d70886fe77cfe',
|
||
'ev_e91787b1d4dea651582c6cf12827775b9068b0ee8b80c68ba08d4c1d87d94e90(rewind.* zh-CN 文案)',
|
||
'ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb(ConversationTimelineImpl rewind 接线,脚本 SHA-256 5f35b748…)'],
|
||
snapshots, checks, rendererErrors }
|
||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
|
||
console.log('USER_MESSAGE_EDIT_' + report.result + ' ' + directory)
|
||
for (const failure of checks.filter(item => !item.pass)) console.log('USER_MESSAGE_EDIT_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)
|
||
})
|
||
}
|