// 真实隔离 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) }) }