211 lines
10 KiB
JavaScript
211 lines
10 KiB
JavaScript
'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 = { 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: '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;
|
|
});
|