feat: complete WorkBuddy parity and Windows release workflow

This commit is contained in:
xiaohan.yuan committed 2026-10-18 12:45:21 +08:00
1 parent 143516b3a8
commit d5512df549
434 files changed
+54977 -1854

No files matched your search

+328
View File
@@ -0,0 +1,328 @@
// TODO 01 running-task acceptance.
// Real isolated YCode App/preload/IPC/renderer + real OMP + a controlled local
// OpenAI-compatible stream that stays open until the task is terminated.
//
// WorkBuddy expectations come from the fixed REA archive:
// - contexts-task-menu-source.js: working/planning/running use the running
// delete confirmation; archive is disabled whenever the status is not
// completed/failed/terminated.
// - ev_f5e9d71a676c453daab40928acfb33aeabae68ba57ffda342380cead7f1a1fe2:
// task menu/modal shell geometry and tokens.
// - item58.js: zh-CN delete.inline.* / archive.dialog.* copy.
const fs = require('node:fs/promises')
const path = require('node:path')
const assert = require('node:assert/strict')
const RUNNING_SESSION_ID = 'task-running-session'
const IDLE_SESSION_ID = 'task-idle-session'
if (!process.versions.electron) {
;(async () => {
const directory = await fs.mkdtemp(path.resolve('.tmp/task-running-'))
const data = path.join(directory, 'data')
const home = path.join(directory, 'home')
const cwd = path.join(directory, 'workspace')
await fs.mkdir(data, { recursive: true })
await fs.mkdir(home, { recursive: true })
await fs.mkdir(cwd, { recursive: true })
const now = Date.now()
const sessions = [
{
id: RUNNING_SESSION_ID,
title: 'Running Menu',
cwd,
modelId: 'local-running',
createdAt: now,
updatedAt: now + 1,
messages: []
},
{
id: IDLE_SESSION_ID,
title: 'Idle Menu',
cwd,
createdAt: now,
updatedAt: now,
messages: []
}
]
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions }))
const env = {
...process.env,
YCODE_QA_DIRECTORY: directory,
YCODE_USER_DATA_DIR: data,
YCODE_OMP_HOME: home,
YCODE_QA_REAL_OMP: process.env.YCODE_OMP_PATH || path.join(process.env.LOCALAPPDATA || '', 'Ygent', 'omp.exe'),
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('TASK_RUNNING_')) 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(-10000))
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
assert.ok(path.relative(path.resolve('.tmp'), directory).startsWith('task-running-'))
process.env.YCODE_OMP_PATH = process.env.YCODE_QA_REAL_OMP
app.setPath('userData', path.join(directory, 'data'))
app.setPath('sessionData', path.join(directory, 'data'))
app.setPath('home', path.join(directory, 'home'))
const checks = []
const snapshots = {}
const rendererErrors = []
let main
let server
let phase = 'boot'
let streamStarted = false
let streamClosed = false
let requestCount = 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, 140))), 20000)
})
])
} finally {
clearTimeout(timer)
}
}
async function wait(source, label, attempts = 750) {
for (let index = 0; index < attempts; index++) {
const value = await read(source)
if (value) return value
await pause(40)
}
throw Error('Timeout: ' + label)
}
async function waitValue(readValue, label, attempts = 750) {
for (let index = 0; index < attempts; index++) {
if (await readValue()) return
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 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 openRunningMenu = `(()=>{const row=document.querySelector('[data-session-id="${RUNNING_SESSION_ID}"]');if(!row)return false;row.classList.add('sidebar__item--menu-open');const button=row.querySelector('.agent-card-more-button');if(!button)return false;button.click();return true})()`
const clickMenuItem = id => `(()=>{const button=document.querySelector('[data-task-menu-id="${id}"]');if(!button)return false;button.click();return true})()`
const fillComposer = text => `(()=>{const input=document.querySelector('.wb-composer__input');if(!(input instanceof HTMLTextAreaElement))return false;const setter=Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set;setter.call(input,${JSON.stringify(text)});input.dispatchEvent(new Event('input',{bubbles:true}));return true})()`
;(async () => {
server = createServer(async (req, res) => {
requestCount += 1
let raw = ''
for await (const part of req) raw += part
const body = JSON.parse(raw || '{}')
if (req.method === 'OPTIONS') {
res.writeHead(204, {
'access-control-allow-origin': '*',
'access-control-allow-headers': '*',
'access-control-allow-methods': '*'
})
res.end()
return
}
if (!body.stream) {
res.setHeader('content-type', 'application/json')
res.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'RUNNING_REPLY' } }] }))
return
}
streamStarted = true
streamClosed = false
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
connection: 'keep-alive'
})
const chunk = payload => `data: ${JSON.stringify({
id: 'local-running',
object: 'chat.completion.chunk',
created: 1,
model: body.model,
...payload
})}\n\n`
res.write(chunk({ choices: [{ index: 0, delta: { role: 'assistant', content: 'RUNNING_REPLY' }, finish_reason: null }] }))
const keepalive = setInterval(() => {
if (!res.destroyed) res.write(': keepalive\n\n')
}, 500)
res.on('close', () => {
clearInterval(keepalive)
streamClosed = true
})
})
const sync = require('node:fs')
server.listen(0, '127.0.0.1')
sync.mkdirSync(process.env.YCODE_USER_DATA_DIR, { recursive: true })
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
await app.whenReady()
await new Promise(resolve => server.listening ? resolve() : server.once('listening', resolve))
const baseUrl = `http://127.0.0.1:${server.address().port}/v1`
for (let index = 0; index < 250; index++) {
main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode')
if (main && !main.webContents.isLoading()) break
await pause(40)
}
assert.ok(main, 'main window')
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('!!window.ycode && !!document.querySelector(".chat-pane")', 'main renderer')
await read(`window.ycode.models.save(${JSON.stringify({
id: 'local-running',
name: 'Local Running',
providerId: 'custom',
model: 'model-running',
baseUrl,
apiKey: 'LOCAL_TEST_KEY',
supportsToolCall: true,
maxInputTokens: 64000,
maxOutputTokens: 4096
})})`)
await read('window.ycode.models.select("local-running")')
await step('start-running-task')
await wait(`!!document.querySelector('[data-session-id="${RUNNING_SESSION_ID}"]')`, 'running session row')
await read(`document.querySelector('[data-session-id="${RUNNING_SESSION_ID}"]').click()`)
await wait('!!document.querySelector(".wb-composer__input")', 'composer input')
check('composer is the recovered textarea', await read(`document.querySelector('.wb-composer__input') instanceof HTMLTextAreaElement`), true)
assert.equal(await read(fillComposer('KEEP_RUNNING')), true)
await wait('!document.querySelector(".cr-send-button:not(.cr-send-button--stop)")?.disabled', 'enabled send button')
await read(`document.querySelector('.cr-send-button:not(.cr-send-button--stop)').click()`)
await wait('!!document.querySelector(".cr-send-button--stop")', 'running stop button')
await waitValue(() => streamStarted, 'controlled upstream stream started')
await wait(`document.querySelector('[data-session-id="${RUNNING_SESSION_ID}"]')?.textContent.includes('运行中')`, 'sidebar running state')
await shot('running-row')
await step('running-menu')
await read(openRunningMenu)
await wait('!!document.querySelector("[data-task-menu-id=archive]")', 'running task menu')
const archive = await read(`(()=>{const button=document.querySelector('[data-task-menu-id="archive"]');const style=getComputedStyle(button);return {disabled:button.getAttribute('aria-disabled'),title:button.getAttribute('title'),className:button.className,color:style.color,cursor:style.cursor}})()`)
snapshots.archive = archive
check('running archive item is disabled', archive.disabled, 'true')
check('running archive uses the recovered reason', archive.title, '任务进行中,无法归档')
check('running archive is visually disabled', [archive.className.includes('cb-context-menu-item--disabled'), archive.cursor], [true, 'not-allowed'])
await read(clickMenuItem('archive'))
await pause(120)
check('clicking disabled archive keeps the menu open', await read('!!document.querySelector(".conversation-context-menu")'), true)
check('clicking disabled archive opens no confirmation', await read('!!document.querySelector(".task-confirm-dialog")'), false)
await shot('running-menu')
const archivedWhileRunning = await read(`window.ycode.session.setArchived(${JSON.stringify(RUNNING_SESSION_ID)},true).then(()=>({ok:true}),error=>({ok:false,message:String(error?.message||error)}))`)
snapshots.archiveRejection = archivedWhileRunning
check('main process rejects archive while the run is active', archivedWhileRunning.ok, false)
check('main-process archive rejection uses the same reason', archivedWhileRunning.message.includes('任务进行中,无法归档'), true)
check('rejected archive leaves the running task visible', await read(`window.ycode.session.list().then(list=>list.some(item=>item.id===${JSON.stringify(RUNNING_SESSION_ID)}))`), true)
await step('running-delete-confirm')
await read(clickMenuItem('delete'))
await wait('!!document.querySelector(".task-confirm-dialog[data-task-confirm-kind=delete]")', 'running delete confirmation')
const runningDialog = await read(`(()=>{const dialog=document.querySelector('.task-confirm-dialog');const style=getComputedStyle(dialog);const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]};return {box:box(dialog),width:style.width,padding:style.padding,borderRadius:style.borderRadius,title:dialog.querySelector('.wb-modal__title')?.textContent,desc:dialog.querySelector('.task-confirm-dialog__description')?.textContent,cancel:dialog.querySelector('.task-confirm-dialog__cancel')?.textContent,confirm:dialog.querySelector('.task-confirm-dialog__confirm')?.textContent,running:dialog.getAttribute('data-task-confirm-running'),ariaBusy:dialog.getAttribute('aria-busy')}})()`)
snapshots.runningDialog = runningDialog
check('running delete uses the recovered title', runningDialog.title, '删除任务?')
check('running delete explains immediate termination', runningDialog.desc, '该任务正在执行中,删除将立即终止运行,且无法恢复。')
check('running delete actions use the recovered labels', [runningDialog.cancel, runningDialog.confirm], ['取消', '停止并删除'])
check('running delete dialog carries the running state', runningDialog.running, 'true')
check('running delete dialog uses the recovered 480px shell', [runningDialog.width, Math.round(runningDialog.box[2]), runningDialog.padding, runningDialog.borderRadius], ['480px', 480, '24px', '24px'])
await shot('running-delete-confirm')
await read(`document.querySelector('.task-confirm-dialog__cancel').click()`)
await wait('!document.querySelector(".task-confirm-dialog")', 'running delete cancellation closes')
check('cancelling running delete preserves the live run', await read('!!document.querySelector(".cr-send-button--stop")'), true)
check('cancelling running delete preserves the session', await read(`window.ycode.session.list().then(list=>list.some(item=>item.id===${JSON.stringify(RUNNING_SESSION_ID)}))`), true)
await step('running-delete-confirm-and-stop')
await read(openRunningMenu)
await wait('!!document.querySelector("[data-task-menu-id=delete]")', 'second running menu')
await read(clickMenuItem('delete'))
await wait('!!document.querySelector(".task-confirm-dialog__confirm")', 'second running confirmation')
await read(`document.querySelector('.task-confirm-dialog__confirm').click()`)
await wait(`window.ycode.session.listArchived().then(list=>!list.some(item=>item.id===${JSON.stringify(RUNNING_SESSION_ID)}))`, 'running delete removes session', 1500)
await wait('!document.querySelector(".cr-send-button--stop")', 'running delete clears stop button', 500)
await waitValue(() => streamClosed, 'aborted upstream stream closes', 500)
check('running delete removes the session from every listing', await read(`window.ycode.session.listArchived().then(list=>list.some(item=>item.id===${JSON.stringify(RUNNING_SESSION_ID)}))`), false)
check('running delete leaves the idle session intact', await read(`window.ycode.session.list().then(list=>list.some(item=>item.id===${JSON.stringify(IDLE_SESSION_ID)}))`), true)
check('running delete terminates the controlled upstream request', streamClosed, true)
check('controlled upstream was actually requested', requestCount > 0, true)
check('no renderer error logs', rendererErrors, [])
const report = {
result: checks.every(item => item.pass) ? 'PASS' : 'FAIL',
scope: 'Real isolated YCode App/preload/IPC/renderer + real OMP + controlled local streaming upstream. Task deletion was confirmed and terminated the live stream. Original expectations come from fixed REA archive ev_f5e9d71a…, contexts-task-menu-source.js and item58.js.',
sourceEvidence: [
'ev_f5e9d71a676c453daab40928acfb33aeabae68ba57ffda342380cead7f1a1fe2'
],
snapshots,
checks,
rendererErrors
}
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
console.log('TASK_RUNNING_' + report.result + ' ' + directory)
for (const failure of checks.filter(item => !item.pass)) {
console.log('TASK_RUNNING_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)
if (main) await shot('failure').catch(() => undefined)
server?.close()
app.exit(1)
})
}