Files
ycode/scripts/verify-user-message-edit.cjs
T

298 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 真实隔离 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 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: crypto.randomUUID(), role: 'user', content: '跨年历史消息', createdAt: otherYear, status: 'done' },
{ id: crypto.randomUUID(), role: 'assistant', content: '跨年回复。', createdAt: otherYear + 1000, status: 'done' },
{ id: crypto.randomUUID(), role: 'user', content: '同年历史消息', createdAt: sameYearDay, status: 'done' },
{ id: crypto.randomUUID(), role: 'assistant', content: '同年回复。', createdAt: sameYearDay + 1000, status: 'done' },
{ id: firstUserId, role: 'user', content: '第一条历史消息', createdAt: now - 4000, status: 'done' },
{ id: crypto.randomUUID(), role: 'assistant', content: '收到第一条。', createdAt: now - 3000, status: 'done' },
{ id: secondUserId, role: 'user', content: 'workbuddy是否有目标模式', createdAt: now - 2000, status: 'done' },
{ id: crypto.randomUUID(), role: 'assistant', content: '本地文档里没有直接命中。', createdAt: now - 1000, status: 'done', 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' }
] }]
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions }))
await fs.writeFile(path.join(directory, 'expected.json'), JSON.stringify({ 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 = []
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。
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: 'edit-user-' + sentPayloads.length, 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)
// 该消息之后存在可回退的产物写入,因此原版会额外显示“恢复此版本”入口(ev_245ea174… 的无产物会话只有 copy + edit)。
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 original aria labels', 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(`(()=>{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 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)
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 有目标模式吗')
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')
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'],
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)
})
}