// 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) }) }