257 lines
20 KiB
JavaScript
257 lines
20 KiB
JavaScript
// TODO 08 验收:新任务发送后的立即切页与占位。
|
||
// 真实隔离 YCode(真实 preload/IPC/渲染进程)+ 真实 OMP + 受控本地 OpenAI 兼容上游。
|
||
//
|
||
// 原版依据(REA ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb,
|
||
// WorkBuddy 5.8.2,见 docs/对话启动提示文案-2026-10-16.md):启动阶段文案取自
|
||
// chat.prepare.<stage>,渲染在流式状态栏主文案位置。YCode 侧使用 acquiring_runtime
|
||
// =「正在启动对话运行环境…」作为主进程回合就绪前的占位文案。
|
||
// 原版「发送后」逐帧等待态没有 REA 运行时证据:REA visual probe 的 text 动作只接受
|
||
// INPUT、key 动作排除可编辑内容上的 Enter/Space(见 .tmp/todo08-wb-new-task/notes.md),
|
||
// 因此本脚本只验收可证行为:发送即切页 + 用户消息先出现 + 准备阶段占位先于真实回合 +
|
||
// 真实消息替换不重复 + 发送失败恢复草稿且不残留乐观消息。
|
||
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/new-task-switch-'))
|
||
const home = path.join(directory, 'home')
|
||
await fs.mkdir(home, { recursive: true })
|
||
const env = { ...process.env, YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_QA_DIRECTORY: directory,
|
||
YCODE_QA_REAL_OMP: process.env.YCODE_OMP_PATH || path.join(process.env.LOCALAPPDATA || '', 'Ygent', 'omp.exe'),
|
||
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('NEW_TASK_SWITCH_')) process.stdout.write(bytes) })
|
||
child.stderr.on('data', bytes => { output += bytes })
|
||
const timer = setTimeout(() => child.kill(), 240000)
|
||
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.slice(-8000))
|
||
process.exitCode = code ? 1 : 0
|
||
})
|
||
})().catch(error => { console.error(error); process.exitCode = 1 })
|
||
} else {
|
||
const { app, BrowserWindow } = require('electron')
|
||
const { createServer } = require('node:http')
|
||
const { pathToFileURL } = require('node:url')
|
||
const directory = process.env.YCODE_QA_DIRECTORY
|
||
app.disableHardwareAcceleration()
|
||
process.env.YCODE_OMP_PATH = process.env.YCODE_QA_REAL_OMP
|
||
app.setPath('home', path.join(directory, 'home'))
|
||
const checks = []
|
||
const snapshots = {}
|
||
const rendererErrors = []
|
||
let main, server, phase = 'boot'
|
||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||
const read = (source, win = main) => Promise.race([win.webContents.executeJavaScript(source),
|
||
new Promise((_, reject) => setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 120))), 20000))])
|
||
async function wait(source, label) {
|
||
for (let index = 0; index < 400; index++) { const value = await read(source); if (value) return value; await pause(40) }
|
||
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 fill(value) {
|
||
await read(`(()=>{const input=[...document.querySelectorAll('.wb-composer__input')].find(node=>node.offsetParent!==null);
|
||
if(!input)throw new Error('Missing composer input');
|
||
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(input,${JSON.stringify(value)});
|
||
input.dispatchEvent(new Event('input',{bubbles:true}));return true})()`)
|
||
}
|
||
const within = limit => value => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value < limit
|
||
|
||
;(async () => {
|
||
// 受控上游:首个响应刻意延迟,保证「真实回合」到达前有一段可观测的占位窗口。
|
||
server = createServer(async (req, res) => {
|
||
let raw = ''
|
||
for await (const part of req) raw += part
|
||
const body = JSON.parse(raw || '{}')
|
||
// 「停止验收」场景需要一个更长的回合,方便在运行中切走/切回再停止。
|
||
await pause(JSON.stringify(body).includes('停止验收') ? 5000 : 600)
|
||
const content = `LOCAL_REPLY_${body.model}`
|
||
if (!body.stream) { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content } }] })); return }
|
||
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' })
|
||
const chunk = delta => `data: ${JSON.stringify({ id: 'local-test', object: 'chat.completion.chunk', created: 1, model: body.model, choices: [{ index: 0, delta, finish_reason: null }] })}\n\n`
|
||
res.write(chunk({ role: 'assistant', content }))
|
||
res.write(`data: ${JSON.stringify({ id: 'local-test', object: 'chat.completion.chunk', created: 1, model: body.model, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 20, completion_tokens: 5, total_tokens: 25 } })}\n\ndata: [DONE]\n\n`)
|
||
res.end()
|
||
})
|
||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
||
const baseUrl = `http://127.0.0.1:${server.address().port}/v1`
|
||
|
||
await fs.mkdir(process.env.YCODE_USER_DATA_DIR, { recursive: true })
|
||
app.setPath('userData', process.env.YCODE_USER_DATA_DIR)
|
||
app.setPath('sessionData', process.env.YCODE_USER_DATA_DIR)
|
||
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
|
||
await app.whenReady()
|
||
for (let index = 0; index < 120; index++) {
|
||
main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode')
|
||
if (main && !main.webContents.isLoading()) break
|
||
await pause(50)
|
||
}
|
||
assert.ok(main)
|
||
main.webContents.on('console-message', (_event, level, message) => { if (level >= 2) rendererErrors.push(message) })
|
||
await wait('!!window.ycode && !!document.querySelector(".chat-pane")', 'main renderer')
|
||
await read(`window.ycode.models.save(${JSON.stringify({ id: 'local-one', name: 'Local One', providerId: 'custom',
|
||
model: 'model-one', baseUrl, apiKey: 'LOCAL_TEST_KEY', supportsToolCall: true, maxInputTokens: 64000, maxOutputTokens: 4096 })})`)
|
||
await read('window.ycode.models.select("local-one")')
|
||
|
||
await step('home-surface')
|
||
await wait(`!!document.querySelector('.wb-home-composer') && !!document.querySelector('.wb-composer__input')`, 'home composer')
|
||
const sessionCountBefore = (await read('window.ycode.session.list()')).length
|
||
check('no session exists before the first send', sessionCountBefore, 0)
|
||
|
||
await step('send-new-task')
|
||
const message = 'QA 新任务发送验收'
|
||
await read(`(()=>{const marks={};const text=${JSON.stringify(message)};
|
||
const state=()=>({conversation:!!document.querySelector('.cr-message-list'),
|
||
user:[...document.querySelectorAll('.cr-self-bubble')].some(node=>node.textContent.includes(text)),
|
||
prepare:document.body.textContent.includes('正在启动对话运行环境…'),
|
||
duration:!!document.querySelector('.cr-streaming-duration'),
|
||
reply:document.body.textContent.includes('LOCAL_REPLY_model-one')});
|
||
const tick=()=>{const now=performance.now();const value=state();
|
||
if(!marks.conversation&&value.conversation)marks.conversation=now;
|
||
if(!marks.userMessage&&value.user)marks.userMessage=now;
|
||
if(!marks.prepareText&&value.prepare)marks.prepareText=now;
|
||
if(marks.prepareText&&!marks.prepareCleared&&!value.prepare)marks.prepareCleared=now;
|
||
if(!marks.duration&&value.duration)marks.duration=now;
|
||
if(!marks.reply&&value.reply)marks.reply=now;};
|
||
window.__qa={t0:0,marks};window.__qaTick=tick;
|
||
const observer=new MutationObserver(tick);observer.observe(document.documentElement,{subtree:true,childList:true,characterData:true});
|
||
tick();return true})()`)
|
||
await fill(message)
|
||
await wait(`(()=>{const button=document.querySelector('button[aria-label="发送"]');return !!button&&!button.disabled})()`, 'send button enabled')
|
||
await read(`(()=>{window.__qa.t0=performance.now();document.querySelector('button[aria-label="发送"]').click();return true})()`)
|
||
await wait(`!!(window.__qa.marks.conversation&&window.__qa.marks.userMessage&&window.__qa.marks.prepareText)`, 'optimistic marks')
|
||
await pause(150)
|
||
snapshots.optimistic = await read(`(()=>{const input=[...document.querySelectorAll('.wb-composer__input')].find(node=>node.offsetParent!==null);
|
||
return {t0:window.__qa.t0,marks:{...window.__qa.marks},inputValue:input?input.value:null,
|
||
userBubbles:[...document.querySelectorAll('.cr-self-bubble')].map(node=>node.textContent.trim()),
|
||
home:!!document.querySelector('.wb-home-composer'),conversation:!!document.querySelector('.cr-message-list'),
|
||
prepare:document.body.textContent.includes('正在启动对话运行环境…'),duration:!!document.querySelector('.cr-streaming-duration')}})()`)
|
||
const marks = snapshots.optimistic.marks
|
||
const t0 = snapshots.optimistic.t0
|
||
check('send switches to the conversation view immediately', marks.conversation - t0, within(500))
|
||
check('user message is visible immediately after send', marks.userMessage - t0, within(500))
|
||
check('startup placeholder appears immediately after send', marks.prepareText - t0, within(500))
|
||
check('streaming duration is running during the wait', marks.duration - t0, within(500))
|
||
check('composer draft is cleared at send time', snapshots.optimistic.inputValue, '')
|
||
check('home surface is left at send time', snapshots.optimistic.home, false)
|
||
check('optimistic user message is on screen', snapshots.optimistic.userBubbles.some(text => text.includes(message)), true)
|
||
check('startup placeholder text is the recovered acquiring_runtime string', snapshots.optimistic.prepare, true)
|
||
|
||
await step('real-turn')
|
||
const sessions = await read('window.ycode.session.list()')
|
||
const sessionId = sessions[0] && sessions[0].id
|
||
check('exactly one session was created for the send', sessions.length, 1)
|
||
const done = await wait(`window.ycode.session.messages(${JSON.stringify(sessionId)}).then(list=>{const last=list.at(-1);return last&&last.status==='done'&&last})`, 'turn finished')
|
||
check('local upstream answered the turn', done.content, 'LOCAL_REPLY_model-one')
|
||
await wait(`document.body.textContent.includes('LOCAL_REPLY_model-one')`, 'reply rendered')
|
||
await pause(250)
|
||
snapshots.finished = await read(`(()=>({userBubbles:[...document.querySelectorAll('.cr-self-bubble')].map(node=>node.textContent.trim()),
|
||
prepare:document.body.textContent.includes('正在启动对话运行环境…'),duration:!!document.querySelector('.cr-streaming-duration'),
|
||
reply:document.body.textContent.includes('LOCAL_REPLY_model-one'),marks:{...window.__qa.marks}}))()`)
|
||
const finalMarks = snapshots.finished.marks
|
||
check('startup placeholder is cleared before the reply arrives', finalMarks.prepareCleared !== undefined && finalMarks.prepareCleared <= (finalMarks.reply ?? Number.POSITIVE_INFINITY), true)
|
||
check('startup placeholder is gone after the turn', snapshots.finished.prepare, false)
|
||
check('streaming placeholder is replaced by the finished footer', snapshots.finished.duration, false)
|
||
check('exactly one user message after the turn (no optimistic duplicate)', snapshots.finished.userBubbles.length, 1)
|
||
check('reply is rendered in the conversation', snapshots.finished.reply, true)
|
||
check('real turn took longer than the optimistic render (placeholder was needed)', finalMarks.reply - finalMarks.userMessage,
|
||
value => typeof value === 'number' && value > 300)
|
||
snapshots.timings = { conversationMs: finalMarks.conversation - t0, userMessageMs: finalMarks.userMessage - t0,
|
||
prepareTextMs: finalMarks.prepareText - t0, durationMs: finalMarks.duration - t0,
|
||
prepareClearedMs: finalMarks.prepareCleared === undefined ? null : finalMarks.prepareCleared - t0,
|
||
replyMs: finalMarks.reply === undefined ? null : finalMarks.reply - t0 }
|
||
|
||
await step('send-failure')
|
||
// 清空模型注册表:发送会在 chat:send 的模型解析阶段被拒绝(早于任何回合/网络请求),
|
||
// 这是「发送失败」路径;同时避免误连外部网关。
|
||
const removed = await read(`window.ycode.models.list()`)
|
||
for (const model of removed.models) await read(`window.ycode.models.remove(${JSON.stringify(model.id)})`)
|
||
const registryAfterRemoval = await read(`window.ycode.models.list()`)
|
||
check('model registry is empty for the failure case', registryAfterRemoval.models.length, 0)
|
||
await read(`document.querySelector('[title="新建任务"]').click()`)
|
||
await wait(`!!document.querySelector('.wb-home-composer')`, 'home surface after new task')
|
||
const failureMessage = 'QA 发送失败草稿恢复'
|
||
await fill(failureMessage)
|
||
await wait(`(()=>{const button=document.querySelector('button[aria-label="发送"]');return !!button&&!button.disabled})()`, 'send enabled again')
|
||
await read(`document.querySelector('button[aria-label="发送"]').click()`)
|
||
// 发送失败的错误可能落在发送框(发送框自身状态)或会话区状态行(store error);
|
||
// 首条消息失败时布局会从对话页切回新建页,发送框重挂载,因此两处都接受。
|
||
await wait(`!!(document.querySelector('.wb-composer__error[role="alert"]')||document.querySelector('.msg__status--error'))`, 'send error surfaced')
|
||
await pause(250)
|
||
snapshots.failure = await read(`(()=>{const input=[...document.querySelectorAll('.wb-composer__input')].find(node=>node.offsetParent!==null);
|
||
const composerError=document.querySelector('.wb-composer__error');const statusError=document.querySelector('.msg__status--error');
|
||
return {inputValue:input?input.value:null,
|
||
userBubbles:document.querySelectorAll('.cr-self-bubble').length,
|
||
composerError:composerError?composerError.textContent:null,statusError:statusError?statusError.textContent:null,
|
||
prepare:document.body.textContent.includes('正在启动对话运行环境…'),home:!!document.querySelector('.wb-home-composer')}})()`)
|
||
check('failed send restores the draft text', snapshots.failure.inputValue, failureMessage)
|
||
check('failed send leaves no optimistic user message', snapshots.failure.userBubbles, 0)
|
||
check('failed send surfaces an error', snapshots.failure.composerError ?? snapshots.failure.statusError,
|
||
value => typeof value === 'string' && /模型/.test(value))
|
||
check('failed send leaves no startup placeholder', snapshots.failure.prepare, false)
|
||
check('failed send returns to the home surface', snapshots.failure.home, true)
|
||
|
||
await step('stop-and-switch')
|
||
await read(`window.ycode.models.save(${JSON.stringify({ id: 'local-one', name: 'Local One', providerId: 'custom',
|
||
model: 'model-one', baseUrl, apiKey: 'LOCAL_TEST_KEY', supportsToolCall: true, maxInputTokens: 64000, maxOutputTokens: 4096 })})`)
|
||
await read('window.ycode.models.select("local-one")')
|
||
const idsBeforeStopScenario = (await read('window.ycode.session.list()')).map(session => session.id)
|
||
await read(`document.querySelector('[title="新建任务"]').click()`)
|
||
await wait(`!!document.querySelector('.wb-home-composer')`, 'home surface for the stop scenario')
|
||
const stopMessage = 'QA 停止验收'
|
||
await fill(stopMessage)
|
||
await wait(`(()=>{const button=document.querySelector('button[aria-label="发送"]');return !!button&&!button.disabled})()`, 'send enabled for the stop scenario')
|
||
await read(`document.querySelector('button[aria-label="发送"]').click()`)
|
||
await wait(`document.body.textContent.includes('正在启动对话运行环境…')`, 'startup placeholder for the stop scenario')
|
||
const stopSessionId = (await read('window.ycode.session.list()')).map(session => session.id)
|
||
.find(id => !idsBeforeStopScenario.includes(id))
|
||
check('stop scenario created exactly one new session', typeof stopSessionId === 'string' && !!stopSessionId, true)
|
||
await wait(`!!document.querySelector('button[aria-label="停止"]')`, 'stop button available while running')
|
||
// 运行中切到新任务再切回:消息与运行状态不丢。
|
||
await read(`document.querySelector('[title="新建任务"]').click()`)
|
||
await wait(`!!document.querySelector('.wb-home-composer') && !document.querySelector('.cr-self-bubble')`, 'switched to a new task mid-turn')
|
||
await read(`(()=>{const row=[...document.querySelectorAll('[data-session-id]')].find(node=>node.getAttribute('data-session-id')===${JSON.stringify(stopSessionId)});if(row)row.click();return !!row})()`)
|
||
await wait(`[...document.querySelectorAll('.cr-self-bubble')].some(node=>node.textContent.includes(${JSON.stringify(stopMessage)}))`, 'session restored after switching back')
|
||
snapshots.switchedBack = await read(`(()=>({user:[...document.querySelectorAll('.cr-self-bubble')].some(node=>node.textContent.includes(${JSON.stringify(stopMessage)})),
|
||
streaming:!!document.querySelector('.wb-cr-streaming-footer'),stop:!!document.querySelector('button[aria-label="停止"]')}))()`)
|
||
check('switching tasks mid-turn keeps the message and the running turn', snapshots.switchedBack.user && snapshots.switchedBack.streaming && snapshots.switchedBack.stop, true)
|
||
await read(`document.querySelector('button[aria-label="停止"]').click()`)
|
||
await wait(`window.ycode.session.messages(${JSON.stringify(stopSessionId)}).then(list=>list.at(-1)?.status==='cancelled')`, 'turn cancelled')
|
||
await pause(250)
|
||
snapshots.stopped = await read(`(()=>({userBubbles:[...document.querySelectorAll('.cr-self-bubble')].map(node=>node.textContent.trim()),
|
||
cancelled:!!document.querySelector('.cr-cancelled-indicator'),streaming:!!document.querySelector('.wb-cr-streaming-footer'),
|
||
prepare:document.body.textContent.includes('正在启动对话运行环境…')}))()`)
|
||
check('cancelled turn keeps exactly one user message', snapshots.stopped.userBubbles.length, 1)
|
||
check('cancelled turn shows the cancelled indicator', snapshots.stopped.cancelled, true)
|
||
check('cancelled turn leaves no streaming placeholder', snapshots.stopped.streaming, false)
|
||
check('cancelled turn leaves no startup placeholder', snapshots.stopped.prepare, 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 with real OMP and a controlled local upstream; new-task send timing, startup placeholder, real-turn replacement and failure recovery measured in the live window.',
|
||
sourceEvidence: ['ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb', 'docs/对话启动提示文案-2026-10-16.md', '.tmp/todo08-wb-new-task/notes.md'],
|
||
snapshots, checks, rendererErrors }
|
||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
|
||
console.log('NEW_TASK_SWITCH_' + report.result + ' ' + directory)
|
||
for (const failure of checks.filter(item => !item.pass)) console.log('NEW_TASK_SWITCH_FAILURE ' + JSON.stringify(failure))
|
||
server.close()
|
||
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)
|
||
server?.close()
|
||
app.exit(1)
|
||
})
|
||
}
|