329 lines
16 KiB
JavaScript
329 lines
16 KiB
JavaScript
// 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)
|
|
})
|
|
}
|