feat(conversation): replicate in-place user message editing (TODO 03)
This commit is contained in:
1 parent
ed31a7c1b4
commit
ee51208931
18 files changed
+992
-67
No files matched your search
@@ -4,6 +4,8 @@ import { ComposerControls } from '../src/renderer/src/workbuddy/composer/Compose
|
||||
import { encodePcmWav, downsamplePcm, VOICE_MAX_DURATION_MS } from '../src/renderer/src/workbuddy/composer/VoiceInputControl'
|
||||
import { buildComposerSendPayload, MAX_ATTACHMENT_BYTES, oversizedAttachmentNames, parseResourcePath, readReferenceUris } from '../src/renderer/src/workbuddy/composer/ComposerControls'
|
||||
import { MessageActionToolbar } from '../src/renderer/src/workbuddy/message-actions/MessageActionToolbar'
|
||||
import { UserMessageToolbar } from '../src/renderer/src/workbuddy/message-actions/UserMessageToolbar'
|
||||
import { UserMessageEditor } from '../src/renderer/src/workbuddy/conversation/UserMessageEditor'
|
||||
import { buildCopyMessagePayload, buildUserMessageClipboard, getSortedRequestMessages, projectSafeMessage, safeStringify, USER_CONTENT_BLOCKS_MIME } from '../src/renderer/src/workbuddy/message-actions/project-safe-message'
|
||||
import type { ChatMessage } from '../src/shared/types'
|
||||
|
||||
@@ -132,11 +134,34 @@ const assistantMarkup = renderToStaticMarkup(<MessageActionToolbar message={assi
|
||||
assert.match(assistantMarkup, /复制/)
|
||||
assert.match(assistantMarkup, /重新生成/)
|
||||
assert.doesNotMatch(assistantMarkup, /编辑/)
|
||||
const userMarkup = renderToStaticMarkup(<MessageActionToolbar message={{ id: 'user', role: 'user', content: 'hi', createdAt: 1 }} onEdit={() => undefined} onRevert={() => undefined} onResend={() => undefined} />)
|
||||
assert.match(userMarkup, /编辑/)
|
||||
assert.match(userMarkup, /重新发送/)
|
||||
assert.doesNotMatch(userMarkup, /重新生成/)
|
||||
assert.doesNotMatch(userMarkup, /赞同|不赞同/, 'feedback actions are assistant-only')
|
||||
// 用户消息工具条已替换为原版 .cr-toolbar(时间 + 复制 + 恢复此版本 + 编辑,不含重新发送)。
|
||||
const userMarkup = renderToStaticMarkup(<UserMessageToolbar message={{ id: 'user', role: 'user', content: 'hi', createdAt: Date.now() }} canEdit onEdit={() => undefined} onRevert={() => undefined} />)
|
||||
assert.match(userMarkup, /class="cr-toolbar"/)
|
||||
assert.match(userMarkup, /cr-toolbar__time/)
|
||||
assert.match(userMarkup, /data-action="copy"/)
|
||||
assert.match(userMarkup, /data-action="revert"/)
|
||||
assert.match(userMarkup, /data-action="edit"/)
|
||||
assert.match(userMarkup, /cr-icon-button__icon/)
|
||||
assert.doesNotMatch(userMarkup, /重新发送|重新生成|赞同|不赞同/)
|
||||
assert.doesNotMatch(userMarkup, /wb-message-action__label/, '工具栏只保留图标,不再渲染文字标签')
|
||||
|
||||
const editorMarkup = renderToStaticMarkup(<UserMessageEditor message={{ id: 'user', role: 'user', content: 'hi', createdAt: 1 }} onSubmit={async () => undefined} onCancel={() => undefined} />)
|
||||
assert.match(editorMarkup, /class="cr-user-message-editor"/)
|
||||
assert.match(editorMarkup, /cr-input-container-wrapper/)
|
||||
assert.match(editorMarkup, /cr-input-editor-host/)
|
||||
assert.match(editorMarkup, /data-slate-editor/)
|
||||
assert.match(editorMarkup, /cr-user-message-editor__hint-icon/)
|
||||
assert.match(editorMarkup, /编辑后将从此处重新开始对话,产物也将回退至此信息前版本/)
|
||||
assert.match(editorMarkup, /cr-user-message-editor__cancel/)
|
||||
assert.match(editorMarkup, /cr-user-message-editor__send/)
|
||||
assert.match(editorMarkup, />取消</)
|
||||
assert.match(editorMarkup, />发送</)
|
||||
assert.doesNotMatch(editorMarkup, /role="alert"/, '有内容时不显示错误')
|
||||
|
||||
const emptyEditorMarkup = renderToStaticMarkup(<UserMessageEditor message={{ id: 'user', role: 'user', content: '', createdAt: 1 }} onSubmit={async () => undefined} onCancel={() => undefined} />)
|
||||
assert.match(emptyEditorMarkup, /消息内容不能为空/)
|
||||
assert.match(emptyEditorMarkup, /role="alert"/)
|
||||
assert.match(emptyEditorMarkup, /cr-user-message-editor__send" disabled/)
|
||||
|
||||
const composerMarkup = renderToStaticMarkup(
|
||||
<ComposerControls value="draft" onChange={() => undefined} onSend={() => undefined} onModeChange={() => undefined} modelSlot={<span>Model slot</span>} workspaceFooter={<span>Workspace slot</span>} sending />
|
||||
|
||||
@@ -355,6 +355,60 @@ async function testActionBranchFailure(bundle: TestBundle, state: TestState): Pr
|
||||
'failed action branch should restore the prior marker')
|
||||
}
|
||||
|
||||
async function testEditArtifactRollback(bundle: TestBundle, state: TestState, workspace: string): Promise<void> {
|
||||
const invoke = invokeFor(state)
|
||||
// 有产物:编辑提交先回退文件,再从该消息处重新开始对话(原版 userMessage.edit.hint)。
|
||||
const rolledPath = join(workspace, 'edit-rollback.txt')
|
||||
const rolledSession = await seedWriteHistory(bundle, state, 'edit-rollback', rolledPath, 'edit-rollback-native-target', 'after')
|
||||
await invoke('chat:send', { sessionId: rolledSession, text: 'edited prompt', action: 'edit', fromMessageId: 'edit-rollback-user' })
|
||||
const rolledMessages = await waitForAssistant(invoke, rolledSession)
|
||||
assert.equal(await readFile(rolledPath, 'utf8'), 'before',
|
||||
'edit submit must roll the artifact back to its version before the message')
|
||||
assert.equal(rolledMessages.length, 2, 'edit keeps the prefix before the edited message and appends the replacement')
|
||||
assert.equal(rolledMessages[0].content, 'edited prompt')
|
||||
assert.ok(rolledMessages[0].nativeEntryId?.startsWith('native-prompt-'), 'replacement turn should receive the new native entryId')
|
||||
|
||||
// 分支失败:撤销文件回退,保持会话不变。
|
||||
const failedPath = join(workspace, 'edit-rollback-branch-failure.txt')
|
||||
const failedSession = await seedWriteHistory(bundle, state, 'edit-rollback-failed', failedPath, 'edit-rollback-failed-native', 'after')
|
||||
const failedBefore = await bundle.sessions.getMessages(failedSession)
|
||||
state.failBranchIds.add('edit-rollback-failed-native')
|
||||
await assert.rejects(invoke('chat:send', {
|
||||
sessionId: failedSession, text: 'edited failed', action: 'edit', fromMessageId: 'edit-rollback-failed-user'
|
||||
}), /branch failure/)
|
||||
state.failBranchIds.delete('edit-rollback-failed-native')
|
||||
assert.equal(await readFile(failedPath, 'utf8'), 'after', 'failed native branch must undo the artifact rollback')
|
||||
assert.deepEqual(await bundle.sessions.getMessages(failedSession), failedBefore,
|
||||
'failed edit must leave the persisted conversation untouched')
|
||||
|
||||
// 无产物改动:直接重开对话,不因缺少文件记录而报错。
|
||||
const plainSession = await invoke('session:create', 'edit-plain') as { id: string }
|
||||
await bundle.sessions.appendMessage(plainSession.id, message('edit-plain-user', 'user', 'plain prompt', { nativeEntryId: 'edit-plain-native' }))
|
||||
await bundle.sessions.appendMessage(plainSession.id, message('edit-plain-assistant', 'assistant', 'plain reply'))
|
||||
seedNativeHistory(state, plainSession.id, [['edit-plain-native', 'plain prompt']])
|
||||
await invoke('chat:send', { sessionId: plainSession.id, text: 'edited plain', action: 'edit', fromMessageId: 'edit-plain-user' })
|
||||
const plainMessages = await waitForAssistant(invoke, plainSession.id)
|
||||
assert.equal(plainMessages.length, 2)
|
||||
assert.equal(plainMessages[0].content, 'edited plain')
|
||||
|
||||
// 记录不完整(不可回滚工具):拒绝编辑,会话与文件都不变。
|
||||
const blockedPath = join(workspace, 'edit-blocked.txt')
|
||||
await writeFile(blockedPath, 'after', 'utf8')
|
||||
const blockedSession = await invoke('session:create', 'edit-blocked') as { id: string }
|
||||
await bundle.sessions.appendMessage(blockedSession.id, message('edit-blocked-user', 'user', 'blocked prompt', { nativeEntryId: 'edit-blocked-native' }))
|
||||
await bundle.sessions.appendMessage(blockedSession.id, message('edit-blocked-assistant', 'assistant', 'ran shell', {
|
||||
tools: [{ id: 'shell-tool', name: 'run_command', args: {}, argsRaw: '{}', status: 'ok' }]
|
||||
}))
|
||||
seedNativeHistory(state, blockedSession.id, [['edit-blocked-native', 'blocked prompt']])
|
||||
const blockedBefore = await bundle.sessions.getMessages(blockedSession.id)
|
||||
await assert.rejects(invoke('chat:send', {
|
||||
sessionId: blockedSession.id, text: 'edited blocked', action: 'edit', fromMessageId: 'edit-blocked-user'
|
||||
}), /无法完整回滚/)
|
||||
assert.equal(await readFile(blockedPath, 'utf8'), 'after', 'rejected edit must not touch artifacts')
|
||||
assert.deepEqual(await bundle.sessions.getMessages(blockedSession.id), blockedBefore,
|
||||
'rejected edit must leave history untouched')
|
||||
}
|
||||
|
||||
async function testInterruptedResend(bundle: TestBundle, state: TestState): Promise<void> {
|
||||
const invoke = invokeFor(state)
|
||||
for (const nativeAccepted of [false, true]) {
|
||||
@@ -684,6 +738,7 @@ async function main(): Promise<void> {
|
||||
await testConcurrency(bundle, state, invoke)
|
||||
for (const action of ['edit', 'resend', 'regenerate'] as const) await testActionBranch(bundle, state, action)
|
||||
await testActionBranchFailure(bundle, state)
|
||||
await testEditArtifactRollback(bundle, state, workspace)
|
||||
await testInterruptedResend(bundle, state)
|
||||
await testAttachments(bundle, state, tempRoot)
|
||||
await testSpeech(bundle, state)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"include_fonts": false,
|
||||
"include_message_content": true,
|
||||
"track_selectors": [
|
||||
".cr-self-bubble",
|
||||
".cr-frame--right",
|
||||
".cr-toolbar"
|
||||
],
|
||||
"actions": [
|
||||
{ "kind": "click", "step_id": "open-first-space-task", "selector": ".conversation-item", "index": 0, "frames": 0 },
|
||||
{ "kind": "wait", "step_id": "conversation-loaded", "duration_ms": 1500, "frames": 1 },
|
||||
{ "kind": "hover", "step_id": "hover-user-message", "selector": ".cr-self-bubble", "index": 0, "frames": 1 },
|
||||
{ "kind": "wait", "step_id": "toolbar-visible", "duration_ms": 400, "frames": 1 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"include_fonts": false,
|
||||
"include_message_content": true,
|
||||
"track_selectors": [
|
||||
".cr-frame--right",
|
||||
".cr-toolbar",
|
||||
".cr-toolbar__time",
|
||||
".cr-toolbar .cr-icon-button[data-action='copy']",
|
||||
".cr-toolbar .cr-icon-button[data-action='revert']",
|
||||
".cr-toolbar .cr-icon-button[data-action='edit']",
|
||||
".cr-self-bubble--editing",
|
||||
".cr-user-message-editor",
|
||||
".cr-user-message-editor .cr-input-editor-host",
|
||||
".cr-user-message-editor__hint",
|
||||
".cr-user-message-editor__cancel",
|
||||
".cr-user-message-editor__send"
|
||||
],
|
||||
"actions": [
|
||||
{ "kind": "hover", "step_id": "hover-user-message", "selector": ".cr-self-bubble", "index": 0, "frames": 1, "interval_ms": 50 },
|
||||
{ "kind": "wait", "step_id": "toolbar-visible", "duration_ms": 350, "frames": 1 },
|
||||
{ "kind": "click", "step_id": "open-editor", "selector": ".cr-toolbar .cr-icon-button[data-action='edit']", "index": 0, "frames": 0 },
|
||||
{ "kind": "wait", "step_id": "editor-visible", "duration_ms": 700, "frames": 1, "interval_ms": 50 },
|
||||
{ "kind": "click", "step_id": "cancel-editor", "selector": ".cr-user-message-editor__cancel", "index": 0, "frames": 0 },
|
||||
{ "kind": "wait", "step_id": "editor-closed", "duration_ms": 500, "frames": 1 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"include_fonts": false,
|
||||
"include_message_content": false,
|
||||
"track_selectors": [
|
||||
".cr-self-bubble",
|
||||
".cr-toolbar",
|
||||
".cr-user-message-editor"
|
||||
],
|
||||
"actions": [
|
||||
{ "kind": "click", "step_id": "cancel-editor", "selector": ".cr-user-message-editor__cancel", "index": 0, "frames": 0 },
|
||||
{ "kind": "wait", "step_id": "editor-closed", "duration_ms": 500, "frames": 1 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"include_fonts": false,
|
||||
"include_message_content": true,
|
||||
"track_selectors": [
|
||||
".cr-self-bubble--editing",
|
||||
".cr-user-message-editor",
|
||||
".cr-user-message-editor .cr-input-container",
|
||||
".cr-user-message-editor .cr-input-editor-host",
|
||||
".cr-user-message-editor__error",
|
||||
".cr-user-message-editor__hint",
|
||||
".cr-user-message-editor__hint-icon",
|
||||
".cr-user-message-editor__actions",
|
||||
".cr-user-message-editor__cancel",
|
||||
".cr-user-message-editor__send",
|
||||
".cr-user-message-editor__resume",
|
||||
".cr-user-message-editor__send svg"
|
||||
],
|
||||
"actions": [
|
||||
{ "kind": "hover", "step_id": "hover-user-message", "selector": ".cr-self-bubble", "index": 0, "frames": 0 },
|
||||
{ "kind": "wait", "step_id": "toolbar-visible", "duration_ms": 300, "frames": 0 },
|
||||
{ "kind": "click", "step_id": "open-editor", "selector": ".cr-toolbar .cr-icon-button[data-action='edit']", "index": 0, "frames": 0 },
|
||||
{ "kind": "wait", "step_id": "editor-open", "duration_ms": 800, "frames": 1 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
// 真实隔离 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 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: 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 } }
|
||||
] }
|
||||
] }]
|
||||
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 }))
|
||||
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 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 two user messages', userBubbles, 2)
|
||||
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('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)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user