'use strict'; const assert = require('assert/strict'); const fs = require('fs'); const os = require('os'); const path = require('path'); const http = require('http'); const { createJavascriptSessionRuntime, packageIndexSource, packageJsonSource, } = require('../lib/javascript-session-runtime'); const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); async function waitForRun(runtime, sourceId, runId, predicate, timeoutMs = 10000) { const startedAt = Date.now(); let latest = null; while (Date.now() - startedAt < timeoutMs) { const result = runtime.getRun({ runId }, sourceId); latest = result; if (result.ok && predicate(result)) return result; await delay(50); } throw new Error(`等待脚本运行状态超时:${runId} ${JSON.stringify(latest)}`); } async function main() { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-javascript-session-unit-')); const cwd = path.join(root, 'workspace'); const sessionsDir = path.join(root, 'sessions'); fs.mkdirSync(cwd, { recursive: true }); fs.mkdirSync(sessionsDir, { recursive: true }); const sourceId = '11111111-1111-4111-8111-111111111111'; const childId = '33333333-3333-4333-8333-333333333333'; const sessions = new Map([[sourceId, { id: sourceId, cwd, messages: [{ role: 'assistant', content: '最后消息' }], }]]); fs.writeFileSync(path.join(sessionsDir, `${sourceId}.json`), JSON.stringify(sessions.get(sourceId))); const notifications = []; let statusCallCount = 0; const statusSequence = [ 'idle', 'idle', 'running', 'idle', 'waiting_for_children', 'idle', 'idle', 'idle', 'idle', ]; const mcpServer = http.createServer((req, res) => { let body = ''; req.setEncoding('utf8'); req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { const payload = JSON.parse(body || '{}'); assert.match(String(req.headers['x-cc-web-mcp-token'] || ''), /^[0-9a-f]{64}$/); let response; switch (payload.tool) { case 'ccweb_script_get_current_conversation_id': response = { ok: true, conversationId: payload.sourceSessionId }; break; case 'ccweb_script_get_last_message': response = payload.args.conversationId === 'error-test' ? { ok: false, code: 'conversation_not_found', message: '目标对话不存在。', conversationId: 'error-test' } : { ok: true, conversationId: payload.args.conversationId, message: '服务端最后消息' }; break; case 'ccweb_script_get_conversation_status': statusCallCount += 1; response = { ok: true, conversationId: payload.args.conversationId, status: statusSequence[Math.min(statusCallCount - 1, statusSequence.length - 1)], }; break; case 'ccweb_script_get_child_conversation_ids': response = { ok: true, conversationId: payload.args.conversationId, childConversationIds: [childId] }; break; default: response = { ok: false, code: 'unexpected_test_tool', message: payload.tool }; } res.writeHead(response.ok ? 200 : 400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(response)); }); }); await new Promise((resolve) => mcpServer.listen(0, '127.0.0.1', resolve)); const mcpUrl = `http://127.0.0.1:${mcpServer.address().port}/api/internal/mcp`; const runtime = createJavascriptSessionRuntime({ sessionsDir, internalMcpUrl: mcpUrl, loadSession: (id) => sessions.get(id) || null, notifyFailure: (id, entry) => notifications.push({ id, entry: { ...entry } }), }); try { assert.equal(runtime.getApiManifest().ok, true); assert.equal(runtime.getApiManifest().packageName, '@ccweb/session'); const manifest = runtime.getApiManifest(); assert.equal(manifest.functions.length, 8); assert.equal(manifest.functions.every((item) => item.description && Array.isArray(item.errors)), true); assert.deepEqual(manifest.statusValues, ['running', 'waiting_for_children', 'idle']); assert.equal(Object.prototype.hasOwnProperty.call(manifest, 'scriptTools'), false); assert.equal(Object.prototype.hasOwnProperty.call(manifest, 'scriptToolExamples'), false); assert.match(packageJsonSource(), /"type": "module"/); assert.match(packageIndexSource(), /export async function sendMessage/); assert.match(packageIndexSource(), /export async function getConversationStatus/); assert.match(packageIndexSource(), /export async function getChildConversationIds/); assert.match(packageIndexSource(), /export async function onConversationEvent/); assert.equal(runtime.createScript({ name: '../escape.js' }, sourceId).code, 'invalid_script_name'); assert.equal(runtime.createScript({ name: 'workflow.txt' }, sourceId).code, 'invalid_script_name'); assert.equal(runtime.createScript({ name: 'workflow.js' }, sourceId).ok, true); assert.equal(runtime.createScript({ name: 'workflow.js' }, sourceId).code, 'script_exists'); const injectedPackageIndex = path.join(cwd, '.ccweb', 'scripts', 'node_modules', '@ccweb', 'session', 'index.js'); fs.writeFileSync(injectedPackageIndex, '// stale generated package\n'); const workflow = [ "import { getCurrentConversationId, getLastMessage, getConversationStatus, getChildConversationIds, onConversationEvent } from '@ccweb/session';", 'const current = await getCurrentConversationId();', 'const initialStatus = await getConversationStatus(current);', 'const children = await getChildConversationIds(current);', 'let unsubscribe = () => {};', 'let resolveIdle;', 'const idlePromise = new Promise((resolve) => { resolveIdle = resolve; });', "unsubscribe = await onConversationEvent(current, 'idle', (event) => { unsubscribe(); resolveIdle(event); });", 'const idleEvent = await idlePromise;', 'await new Promise((resolve) => setTimeout(resolve, 250));', "let invalidEventCode = '';", "try { await onConversationEvent(current, 'running', () => {}); } catch (error) { invalidEventCode = error.code; }", 'console.log(JSON.stringify({ current, last: await getLastMessage(current), initialStatus, children, idleEvent, invalidEventCode }));', ].join('\n'); assert.equal(runtime.writeScript({ name: 'workflow.js', content: workflow }, sourceId).ok, true); assert.match(fs.readFileSync(injectedPackageIndex, 'utf8'), /export async function onConversationEvent/); assert.equal(runtime.writeScript({ name: 'too-large.js', content: 'x'.repeat(1024 * 1024 + 1) }, sourceId).code, 'script_content_too_large'); const started = runtime.runScript({ name: 'workflow.js' }, sourceId); assert.equal(started.ok, true); const duplicate = runtime.runScript({ name: 'workflow.js' }, sourceId); assert.equal(duplicate.code, 'script_already_running'); const succeeded = await waitForRun(runtime, sourceId, started.runId, (run) => run.status === 'succeeded'); assert.equal(succeeded.exitCode, 0); assert.match(succeeded.stdout, new RegExp(sourceId)); assert.match(succeeded.stdout, /服务端最后消息/); const workflowResult = JSON.parse(succeeded.stdout.trim()); assert.equal(workflowResult.initialStatus, 'idle'); assert.deepEqual(workflowResult.children, [childId]); assert.equal(workflowResult.idleEvent.previousStatus, 'waiting_for_children'); assert.equal(workflowResult.idleEvent.status, 'idle'); assert.equal(workflowResult.invalidEventCode, 'conversation_event_invalid'); assert.equal(statusCallCount, statusSequence.length, '瞬时 idle 不应触发,注销后不应继续查询状态'); assert.equal(succeeded.stderr, ''); assert.equal(notifications.length, 0); assert.equal(runtime.writeScript({ name: 'error.js', content: [ "import { getLastMessage } from '@ccweb/session';", "try { await getLastMessage('error-test'); } catch (error) {", " console.log(JSON.stringify({ name: error.name, code: error.code, message: error.message, details: error.details }));", '}', ].join('\n'), }, sourceId).ok, true); const errorStart = runtime.runScript({ name: 'error.js' }, sourceId); const errorRun = await waitForRun(runtime, sourceId, errorStart.runId, (run) => run.status === 'succeeded'); assert.equal(errorRun.exitCode, 0); const errorResult = JSON.parse(errorRun.stdout.trim()); assert.equal(errorResult.name, 'Error'); assert.equal(errorResult.code, 'conversation_not_found'); assert.equal(errorResult.message, '目标对话不存在。'); assert.equal(errorResult.details.conversationId, 'error-test'); assert.equal(runtime.writeScript({ name: 'failed.js', content: "console.error('expected failure'); process.exit(3);" }, sourceId).ok, true); const failedStart = runtime.runScript({ name: 'failed.js' }, sourceId); const failed = await waitForRun(runtime, sourceId, failedStart.runId, (run) => run.status === 'failed'); assert.equal(failed.exitCode, 3); assert.match(failed.stderr, /expected failure/); assert.equal(notifications.length, 1); assert.equal(notifications[0].entry.runId, failedStart.runId); assert.equal(runtime.writeScript({ name: 'long.js', content: "setInterval(() => console.log('running'), 50);" }, sourceId).ok, true); const longStart = runtime.runScript({ name: 'long.js' }, sourceId); assert.equal(runtime.stopScript({ runId: longStart.runId }, sourceId).status, 'stopping'); const killed = await waitForRun(runtime, sourceId, longStart.runId, (run) => run.status === 'killed'); assert.equal(killed.terminationReason, 'stopped_by_request'); assert.equal(notifications.length, 1, '主动停止不应触发异常通知'); const symlinkPath = path.join(cwd, 'outside.js'); fs.writeFileSync(symlinkPath, 'console.log(1);'); try { fs.symlinkSync(symlinkPath, path.join(cwd, '.ccweb', 'scripts', 'link.js')); assert.equal(runtime.runScript({ name: 'link.js' }, sourceId).code, 'script_symlink_forbidden'); } catch (error) { if (!['EPERM', 'EACCES'].includes(error?.code)) throw error; } const recoveredRunId = '22222222-2222-4222-8222-222222222222'; const recoveredDir = path.join(cwd, '.ccweb', 'scripts', '.runs', recoveredRunId); fs.mkdirSync(recoveredDir, { recursive: true }); fs.writeFileSync(path.join(recoveredDir, 'run.json'), JSON.stringify({ runId: recoveredRunId, sourceConversationId: sourceId, name: 'old.js', status: 'running', startedAt: new Date(Date.now() - 1000).toISOString(), tokenRevoked: false, })); runtime.recover(); const recovered = JSON.parse(fs.readFileSync(path.join(recoveredDir, 'run.json'), 'utf8')); assert.equal(recovered.status, 'killed'); assert.equal(recovered.terminationReason, 'server_restarted'); assert.equal(notifications.some((item) => item.entry.runId === recoveredRunId), true); console.log('javascript-session-runtime-unit: ok'); } finally { await new Promise((resolve) => mcpServer.close(resolve)); fs.rmSync(root, { recursive: true, force: true }); } } main().catch((error) => { console.error(error.stack || error.message || error); process.exitCode = 1; });