Files
ycode/scripts/test-composer-connectors.mjs
T

84 lines
16 KiB
JavaScript

/** Real React hook in isolated Electron; all MCP calls are controlled, with no network/user registry. */
import fs from 'node:fs'
import path from 'node:path'
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import { spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
const require = createRequire(import.meta.url), electronModule = require('electron')
const electron = typeof electronModule === 'object' && electronModule.app ? electronModule : null
const root = path.resolve('.'), base = path.join(root, '.tmp')
if (!electron) {
fs.mkdirSync(base, { recursive: true })
const output = fs.mkdtempSync(path.join(base, 'composer-connectors-')), env = { ...process.env }
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
const child = spawn(electronModule, ['--no-sandbox', '--disable-gpu-sandbox', path.resolve('scripts/test-composer-connectors.mjs'), output], { env, windowsHide: true, stdio: 'inherit' })
const timer = setTimeout(() => child.kill(), 60000)
process.exitCode = await new Promise(resolve => child.once('exit', code => { clearTimeout(timer); resolve(code ?? 1) }))
} else void (async () => {
const { app, BrowserWindow } = electron, output = path.resolve(process.argv.at(-1))
assert.ok(path.relative(base, output).startsWith('composer-connectors-') && !path.relative(base, output).includes('..'))
fs.mkdirSync(path.join(output, 'data')); app.setPath('userData', path.join(output, 'data')); app.disableHardwareAcceleration(); await app.whenReady()
const sourceFile = path.join(root, 'src/renderer/src/capabilities/useComposerConnectors.ts')
const sourceSha256 = createHash('sha256').update(fs.readFileSync(sourceFile)).digest('hex')
fs.writeFileSync(path.join(output, 'fixture.tsx'), `
import React,{useState}from'react';import{createRoot}from'react-dom/client';import{useComposerConnectors}from ${JSON.stringify(sourceFile.replaceAll('\\', '/'))};
const w=window as any,clone=value=>JSON.parse(JSON.stringify(value));
const server=(id,enabled=false,trusted=true,status='disconnected')=>({id,name:id,transport:'stdio',command:'CONTROLLED',enabled,trusted,status,configHash:'QA',tools:[],envKeys:[],headerKeys:[]});
const q=w.__qa={calls:[],plans:{},pending:[],results:{},promises:{},history:[],servers:[server('a'),server('b',true),server('untrusted',false,false)],server};
q.plan=(method,type='defer',error='CONTROLLED_FAILURE')=>(q.plans[method]??=[]).push({type,error});
async function host(method,args){q.calls.push([method,...args]);const plan=q.plans[method]?.shift();if(plan?.type==='reject')throw Error(plan.error);if(plan?.type==='defer')await new Promise((resolve,reject)=>q.pending.push({method,resolve,reject}));if(method==='list')return clone(q.servers);const item=q.servers.find(item=>item.id===args[0]);if(!item)throw Error('MCP 服务已移除');if(method==='setEnabled'){if(args[1]&&!item.trusted)throw Error('请先批准当前 MCP 配置');item.enabled=args[1];item.status=args[1]?'connected':'disconnected'}if(method==='connect'){if(!item.trusted)throw Error('请先批准当前 MCP 配置');item.status='connected'}if(method==='disconnect')item.status='disconnected';if(plan?.type==='statusError'){item.status='error';item.error=plan.error}else delete item.error;return clone(item)}
w.ycode={mcp:Object.fromEntries(['list','connect','disconnect','setEnabled','trust'].map(method=>[method,(...args)=>host(method,args)]))};
q.resolve=(index=0)=>q.pending.splice(index,1)[0].resolve();q.reject=(text,index=0)=>q.pending.splice(index,1)[0].reject(Error(text));
function Fixture(){const[owner,setOwner]=useState('A');q.setOwner=setOwner;const hook=useComposerConnectors(owner);q.hook=hook;q.view=clone({owner,items:hook.items,loading:hook.loading,error:hook.error,pendingId:hook.pendingId});q.history.push(q.view);return <pre>{JSON.stringify(q.view)}</pre>}
const reactRoot=createRoot(document.getElementById('root')!);q.mount=()=>reactRoot.render(<Fixture/>);q.unmount=()=>reactRoot.render(null);q.invoke=(method,args,key)=>{const promise=q.hook[method](...args);q.promises[key]=promise;promise.then(()=>q.results[key]='ok',error=>q.results[key]=error.message);return true};q.mount();
`)
await require('esbuild').build({ entryPoints: [path.join(output, 'fixture.tsx')], bundle: true, platform: 'browser', format: 'iife', jsx: 'automatic', outfile: path.join(output, 'fixture.js'), logLevel: 'warning' })
fs.writeFileSync(path.join(output, 'index.html'), '<html><head><meta charset="utf-8"></head><body><div id="root"></div><script src="fixture.js"></script></body></html>')
const win = new BrowserWindow({ show: false, webPreferences: { contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } }), checks = [], errors = []
win.webContents.on('console-message', event => { if (event.level === 'error' || event.level >= 3) errors.push(event.message) })
const read = code => win.webContents.executeJavaScript(code), pause = ms => new Promise(resolve => setTimeout(resolve, ms))
const check = (label, actual, expected = true) => { assert.deepEqual(actual, expected, label); checks.push({ label, pass: true }) }
const wait = async code => { for (let i = 0; i < 160; i++) { if (await read(code)) return; await pause(15) } throw Error('Timeout: ' + code) }
const invoke = async (method, args, key) => { await read(`__qa.invoke(${JSON.stringify(method)},${JSON.stringify(args)},${JSON.stringify(key)})`); await wait(`!!__qa.results[${JSON.stringify(key)}]`) }
try {
await win.loadFile(path.join(output, 'index.html')); await wait('!!window.__qa?.view&&!__qa.view.loading')
check('initial load lists only and does not auto-connect', await read('__qa.calls'), [['list']])
check('actual disabled registry state is retained', await read('__qa.view.items[0].enabled'), false)
await invoke('change', ['a'], 'enable'); check('undefined enables disabled connector via real setEnabled', await read('__qa.calls.slice(1)'), [['list'], ['setEnabled', 'a', true], ['list']])
check('enabled result comes from final list', await read('[__qa.view.items[0].enabled,__qa.view.items[0].status,__qa.view.pendingId??null,__qa.view.loading]'), [true, 'connected', null, false])
await invoke('change', ['a'], 'already'); check('already connected undefined action does not reconnect', await read('__qa.calls.slice(-2)'), [['list'], ['list']])
await invoke('change', ['b'], 'connect'); check('enabled disconnected connector calls connect', await read('__qa.calls.slice(-3)'), [['list'], ['connect', 'b'], ['list']])
await invoke('change', ['a', false], 'off'); check('explicit disable calls real setEnabled false', await read('__qa.calls.slice(-2)'), [['setEnabled', 'a', false], ['list']]); check('disabled view reflects host', await read('__qa.view.items[0].status'), 'disconnected')
await invoke('change', ['a', true], 'on'); check('explicit enable calls setEnabled once without duplicate connect', await read('__qa.calls.slice(-2)'), [['setEnabled', 'a', true], ['list']])
await invoke('change', ['untrusted'], 'trust'); check('untrusted configuration returns actual approval error', await read('[__qa.results.trust,__qa.view.error]'), ['请先批准当前 MCP 配置', '请先批准当前 MCP 配置']); check('no implicit trust or enabling failed server', await read('__qa.calls.every(call=>call[0]!=="trust")&&!__qa.view.items.find(s=>s.id==="untrusted").enabled'))
await read('__qa.servers[1].status="disconnected";__qa.plan("connect","statusError","CONTROLLED_STATUS_ERROR");true'); await invoke('change', ['b'], 'status'); check('resolved status:error is reported rather than fake success', await read('[__qa.results.status,__qa.view.error,__qa.view.items[1].status]'), ['CONTROLLED_STATUS_ERROR', 'CONTROLLED_STATUS_ERROR', 'error'])
await read('__qa.plan("connect","reject","CONTROLLED_CONNECT_REJECTION");true'); await invoke('change', ['b'], 'reject'); check('rejection remains visible after successful final refresh', await read('__qa.view.error'), 'CONTROLLED_CONNECT_REJECTION'); check('failed operation still refreshes actual state', await read('__qa.calls.at(-1)'), ['list'])
await read('__qa.plan("list","reject","CONTROLLED_LIST_ERROR");__qa.hook.refresh();true'); await wait('!__qa.view.loading&&__qa.view.error==="CONTROLLED_LIST_ERROR"'); check('list failure clears loading without attempting connect', await read('__qa.calls.at(-1)'), ['list'])
await read('__qa.plan("connect");__qa.invoke("change",["b"],"slow");true'); await wait('__qa.pending.some(p=>p.method==="connect")'); check('inflight pending id is exposed', await read('__qa.view.pendingId'), 'b')
await read('__qa.invoke("change",["b"],"duplicate");true'); check('identical duplicate reuses promise', await read('__qa.promises.slow===__qa.promises.duplicate')); check('only one connect request while pending', await read('__qa.pending.filter(p=>p.method==="connect").length'), 1)
await invoke('change', ['b', false], 'conflict'); check('conflicting change rejected without config mutation', await read('__qa.results.conflict'), '连接器正在操作,请稍后重试')
await read('__qa.invoke("cancel",["b"],"cancel");true'); await wait('__qa.calls.at(-1)[0]==="disconnect"'); check('cancel immediately invokes real disconnect during connect', await read('__qa.servers[1].status'), 'disconnected')
await read('__qa.invoke("cancel",["b"],"cancelDuplicate");true'); check('duplicate cancellation reuses promise', await read('__qa.promises.cancel===__qa.promises.cancelDuplicate'))
await read('__qa.resolve();true'); await wait('__qa.results.cancel==="ok"'); check('cancel disconnects again after pending connect settles', await read('__qa.calls.slice(-3)'), [['disconnect', 'b'], ['disconnect', 'b'], ['list']]); check('final cancel view reflects disconnected host', await read('[__qa.view.items[1].status,__qa.view.pendingId??null,__qa.view.error]'), ['disconnected', null, ''])
await read('__qa.plan("disconnect","reject","CONTROLLED_DISCONNECT_ERROR");true'); await invoke('cancel', ['b'], 'cancelFail'); check('disconnect failure is reported and actual state refreshed', await read('[__qa.results.cancelFail,__qa.view.error,__qa.calls.at(-1)]'), ['CONTROLLED_DISCONNECT_ERROR', 'CONTROLLED_DISCONNECT_ERROR', ['list']])
await read('__qa.servers[0].enabled=false;__qa.servers[0].status="disconnected";__qa.plan("list");__qa.invoke("change",["a"],"preflight");true'); await wait('__qa.pending.length===1'); await read('__qa.invoke("cancel",["a"],"preflightCancel");true'); await wait('__qa.calls.at(-1)[0]==="disconnect"'); await read('__qa.resolve();true'); await wait('__qa.results.preflightCancel==="ok"'); check('cancel during disabled-enable preflight waits then disconnects created transport', await read('__qa.calls.slice(-3)'), [['setEnabled', 'a', true], ['disconnect', 'a'], ['list']]); check('preflight cancellation retains real global enabled but disconnected state', await read('[__qa.view.items[0].enabled,__qa.view.items[0].status]'), [true, 'disconnected'])
await read('__qa.plan("list");__qa.plan("list");__qa.hook.refresh();__qa.hook.refresh();true'); await wait('__qa.pending.length===2'); await read('__qa.servers[0].name="newer";__qa.resolve(1);true'); await wait('!__qa.view.loading&&__qa.view.items[0].name==="newer"'); await read('__qa.servers[0].name="stale";__qa.resolve(0);true'); await pause(40); check('older list response cannot replace newer refresh', await read('__qa.view.items[0].name'), 'newer')
await read('__qa.plan("connect");__qa.invoke("change",["b"],"oldOwner");true'); await wait('__qa.pending.length===1'); await read('__qa.plan("list");__qa.setOwner("B");true'); await wait('__qa.view.owner==="B"&&__qa.pending.length===2'); check('owner change immediately clears list/error/pending and loads independently', await read('[__qa.view.items,__qa.view.error,__qa.view.pendingId??null,__qa.view.loading]'), [[], '', null, true])
await read('__qa.reject("STALE_OWNER_ACTION",0);true'); await wait('__qa.results.oldOwner==="STALE_OWNER_ACTION"'); check('old-owner action failure cannot poison new owner UI', await read('[__qa.view.items,__qa.view.error,__qa.view.pendingId??null,__qa.view.loading]'), [[], '', null, true]); await read('__qa.resolve();true'); await wait('!__qa.view.loading'); check('global registry is read normally for new owner', await read('__qa.view.items.length'), 3)
await read('__qa.plan("list");__qa.hook.refresh();true'); await wait('__qa.pending.length===1'); await read('__qa.plan("list");__qa.setOwner("A");true'); await wait('__qa.pending.length===2&&__qa.view.owner==="A"'); await read('__qa.servers[0].name="A-fresh";__qa.resolve(1);true'); await wait('!__qa.view.loading'); await read('__qa.servers[0].name="B-stale";__qa.resolve();true'); await pause(40); check('A-B-A scopes use identity rather than only owner string', await read('__qa.view.items[0].name'), 'A-fresh')
await read('__qa.oldHook=__qa.hook;__qa.plan("list");__qa.hook.refresh();true'); await wait('__qa.pending.length===1'); await read('__qa.setOwner("B");true'); await wait('__qa.view.owner==="B"&&!__qa.view.loading'); await read('__qa.plan("list");__qa.setOwner("A");true'); await wait('__qa.view.owner==="A"&&__qa.pending.length===2'); await read('__qa.servers[0].name="old-A-result";__qa.resolve(0);true'); await pause(30); check('returning to same owner still ignores its previous incarnation list', await read('[__qa.view.items,__qa.view.loading,__qa.view.error]'), [[], true, '']); await read('__qa.servers[0].name="new-A-result";__qa.resolve();true'); await wait('!__qa.view.loading'); check('new same-owner scope accepts only its own list', await read('__qa.view.items[0].name'), 'new-A-result'); await read('__qa.oldHook.change("a",false).then(()=>__qa.staleCallback="unexpected",e=>__qa.staleCallback=e.message);true'); await wait('!!__qa.staleCallback'); check('stale same-owner callback cannot mutate registry or new UI', await read('[__qa.staleCallback,__qa.servers[0].enabled,__qa.view.error]'), ['连接器菜单已切换,请重试', true, ''])
await read('__qa.plan("list");__qa.hook.refresh();true'); await wait('__qa.pending.length===1'); await read('__qa.unmount();true'); await pause(30); const history = await read('__qa.history.length'); await read('__qa.reject("UNMOUNTED_ERROR");true'); await pause(40); check('unmount prevents late list state updates', await read('__qa.history.length'), history)
await read('__qa.mount();true'); await wait('!__qa.view.loading'); check('remount starts clean without previous errors', await read('__qa.view.error'), '')
await read('__qa.originalList=window.ycode.mcp.list;window.ycode.mcp.list=()=>{throw Error("SYNC_HOST_ERROR")};__qa.hook.refresh();true'); await wait('!__qa.view.loading&&__qa.view.error==="SYNC_HOST_ERROR"'); check('synchronous host failure handled by refresh', await read('__qa.view.error'), 'SYNC_HOST_ERROR'); await read('window.ycode.mcp.list=__qa.originalList;true')
await read('__qa.servers[1].status="disconnected";__qa.plan("connect");__qa.invoke("change",["b"],"unmountAction");true'); await wait('__qa.pending.length===1'); await read('__qa.unmount();true'); await pause(30); const before = await read('__qa.history.length'); await read('__qa.resolve();true'); await wait('__qa.results.unmountAction==="ok"'); check('unmount prevents late action/final refresh UI updates', await read('__qa.history.length'), before)
check('no implicit approval throughout tests', await read('__qa.calls.every(call=>call[0]!=="trust")')); check('no renderer exceptions', errors, [])
check('production hook source unchanged during QA', createHash('sha256').update(fs.readFileSync(sourceFile)).digest('hex'), sourceSha256)
fs.writeFileSync(path.join(output, 'report.json'), JSON.stringify({ result: 'PASS', checks, errors, sourceSha256, limitations: 'Actual React/Electron hook with controlled MCP API. No user MCP connection, original UI, or production IPC/network exercised.' }, null, 2))
console.log(JSON.stringify({ result: 'PASS', checks: checks.length, output })); win.destroy(); app.exit(0)
} catch (error) {
fs.writeFileSync(path.join(output, 'report.json'), JSON.stringify({ result: 'FAIL', error: error.stack, checks, errors, state: await read('JSON.stringify({view:__qa.view,calls:__qa.calls,results:__qa.results,pending:__qa.pending.map(p=>p.method)})') }, null, 2))
console.error(JSON.stringify({ result: 'FAIL', error: String(error), output })); win.destroy(); app.exit(1)
}
})().catch(error => { console.error(error); electron.app.exit(1) })