Files
ycode/scripts/verify-local-workflows.cjs
T

142 lines
11 KiB
JavaScript

// Real Electron preload/IPC, real OMP process, controlled local OpenAI-compatible upstream.
// This verifies YCode functionality; original WorkBuddy parity is assessed separately via REA.
const fs = require('node:fs/promises')
const path = require('node:path')
const assert = require('node:assert/strict')
if (!process.versions.electron) {
const { spawn } = require('node:child_process')
;(async () => {
const directory = await fs.mkdtemp(path.resolve('.tmp/local-workflows-'))
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
const child = spawn(require('electron'), ['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', chunk => { output += chunk; if (chunk.toString().includes('LOCAL_WORKFLOWS_PASS')) process.stdout.write(chunk) })
child.stderr.on('data', chunk => { output += chunk })
const timeout = setTimeout(() => child.kill(), 120000)
child.once('exit', async code => {
clearTimeout(timeout)
await fs.writeFile(path.join(directory, 'process.log'), output)
console.log(JSON.stringify({ directory, exitCode: code }))
if (code) console.error(output.slice(-6000))
process.exitCode = code ?? 1
})
})().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 requests = []
let main, settings, server
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
const read = (expression, win = main) => Promise.race([win.webContents.executeJavaScript(expression), new Promise((_, reject) => setTimeout(() => reject(new Error('Renderer call timeout: ' + expression.slice(0,180))), 15000))])
const phase = label => fs.writeFile(path.join(directory, 'phase.txt'), label)
async function wait(expression, label, win = main) {
for (let i = 0; i < 500; i++) { const result = await read(expression, win); if (result) return result; await pause(40) }
throw new Error('Timed out: ' + label)
}
async function fill(selector, value, win = main) {
await read(`(()=>{const input=document.querySelector(${JSON.stringify(selector)});if(!input)throw new Error('Missing input');const proto=input.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype:HTMLInputElement.prototype;Object.getOwnPropertyDescriptor(proto,'value').set.call(input,${JSON.stringify(value)});input.dispatchEvent(new Event('input',{bubbles:true}))})()`, win)
}
async function shot(name, win = main) {
await phase('shot-fonts-' + name)
await read('document.fonts.ready.then(()=>true)', win)
await phase('shot-capture-' + name)
await pause(100)
const image = await Promise.race([win.webContents.capturePage(undefined, { stayHidden: true, stayAwake: true }), pause(10000).then(() => { throw new Error('capturePage timed out: ' + name) })])
await fs.writeFile(path.join(directory, name + '.png'), image.toPNG())
}
;(async () => {
// Refuse to let a functional test write OMP profiles in the user's normal home.
assert.equal(path.resolve(process.env.YCODE_OMP_HOME), path.resolve(directory, 'home'))
server = createServer(async (req, res) => {
let raw = ''; for await (const part of req) raw += part
const body = JSON.parse(raw)
requests.push({ model: body.model, stream: body.stream, authenticated: req.headers.authorization === 'Bearer LOCAL_TEST_KEY',
messages: body.messages.map(message => ({ role: message.role, content: message.content })) })
if (req.headers.authorization !== 'Bearer LOCAL_TEST_KEY') { res.writeHead(401); res.end('{}'); return }
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()
assert.equal(path.resolve(app.getPath('userData')), path.resolve(process.env.YCODE_USER_DATA_DIR))
for (let i = 0; i < 100; i++) { main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode'); if (main && !main.webContents.isLoading()) break; await pause(50) }
assert.ok(main)
await wait('!!window.ycode && !!document.querySelector(".chat-pane")', 'main renderer')
await phase('main-ready')
assert.equal(path.resolve((await read('window.ycode.app.info()')).configDir), path.resolve(process.env.YCODE_USER_DATA_DIR))
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 read('window.ycode.workspace.generate("空间甲")')
const spaces = await read('window.ycode.workspace.generate("空间乙")')
assert.equal(spaces.workspaces.length, 2)
for (const space of spaces.workspaces) assert.ok((await fs.stat(space.root)).isDirectory())
const sessionA = await read(`window.ycode.session.create('A',${JSON.stringify({ workspaceId: spaces.workspaces[0].id, modelId: 'local-one' })})`)
await fs.writeFile(path.join(spaces.workspaces[0].root, 'sentinel.txt'), 'SPACE_A')
await fs.writeFile(path.join(spaces.workspaces[1].root, 'sentinel.txt'), 'SPACE_B')
const sessionB = await read(`window.ycode.session.create('B',${JSON.stringify({ workspaceId: spaces.workspaces[1].id, modelId: 'local-one' })})`)
assert.equal(await read(`window.ycode.resources.readText('sentinel.txt',${JSON.stringify(sessionA.id)})`), 'SPACE_A')
assert.equal(await read(`window.ycode.resources.readText('sentinel.txt',${JSON.stringify(sessionB.id)})`), 'SPACE_B')
await read(`window.ycode.chat.send({sessionId:${JSON.stringify(sessionA.id)},text:'Please reply with one short sentence.'})`)
const doneA = await wait(`window.ycode.session.messages(${JSON.stringify(sessionA.id)}).then(messages=>messages.at(-1)?.status!=='streaming'&&messages.at(-1))`, 'real OMP turn A')
assert.equal(doneA.status, 'done', JSON.stringify(doneA))
assert.equal(doneA.content, 'LOCAL_REPLY_model-one')
await phase('first-real-omp-turn-passed')
await read(`window.ycode.models.save(${JSON.stringify({ id: 'local-one', previousId: 'local-one', name: 'Local Edited', providerId: 'custom', model: 'model-two', baseUrl, supportsToolCall: true, maxInputTokens: 64000, maxOutputTokens: 4096 })})`)
await read(`window.ycode.chat.send({sessionId:${JSON.stringify(sessionA.id)},text:'Reply again after changing the model.'})`)
const doneB = await wait(`window.ycode.session.messages(${JSON.stringify(sessionA.id)}).then(messages=>messages.length===4&&messages.at(-1)?.status!=='streaming'&&messages.at(-1))`, 'real OMP changed model')
assert.equal(doneB.status, 'done', JSON.stringify(doneB))
assert.equal(doneB.content, 'LOCAL_REPLY_model-two')
await phase('changed-model-real-omp-turn-passed')
assert.ok(requests.some(req => req.model === 'model-one' && req.authenticated))
assert.ok(requests.some(req => req.model === 'model-two' && req.authenticated))
await phase('opening-settings')
await read('void window.ycode.app.openSettings("model")')
for (let i = 0; i < 100; i++) { settings = BrowserWindow.getAllWindows().find(win => win !== main); if (settings && !settings.webContents.isLoading()) break; await pause(40) }
await wait('!!document.querySelector(".models-settings-panel__add-button")', 'settings model list', settings)
await phase('settings-loaded')
await phase('settings-get-size')
assert.deepEqual(await read('[innerWidth,innerHeight]', settings), [900, 680])
await phase('settings-size-passed')
await shot('models-list', settings)
await read('document.querySelector(".models-settings-panel__add-button").click()', settings)
await wait('!!document.querySelector(".models-settings-panel__editor")', 'model editor', settings)
await shot('model-add', settings)
await read('document.querySelector(".models-settings-panel__editor-cancel").click()', settings)
settings.close()
const file = JSON.parse(await fs.readFile(path.join(directory, 'data', 'models.json'), 'utf8'))
assert.equal(file.models.find(model => model.id === 'local-one').model, 'model-two')
const yamlFiles = await fs.readdir(path.join(directory, 'home', '.omp', 'profiles'))
for (const profile of yamlFiles) assert.ok(!(await fs.readFile(path.join(directory, 'home', '.omp', 'profiles', profile, 'agent', 'models.yml'), 'utf8')).includes('LOCAL_TEST_KEY'))
const report = { scope: 'Real YCode Electron + preload + IPC + real OMP; controlled local upstream, not WorkBuddy parity or live supplier validation',
sessions: [sessionA, sessionB], requests, result: 'PASS' }
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
console.log('LOCAL_WORKFLOWS_PASS ' + directory)
server.close(); app.quit()
})().catch(async error => {
console.error(error)
if (main && !main.isDestroyed()) await shot('failure').catch(() => undefined)
await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({ error: String(error), requests }, null, 2))
server?.close(); app.exit(1)
})
}