Files
cc-3-8-x-mcp/router/test/editor-control.test.js
T
furao b180bd290b [router] 修复: editor spawn/restart 加跨进程锁与调试现场保护
- spawn 锁: 注册目录 per-projectPath lockfile (wx 原子创建, 死持有者可夺锁),
  串行化 kill→spawn→ready, 防多 agent 并发重启造成同项目双开
- restart 在旧进程 SIGKILL 后仍存活时中止, 不再带病 spawn
- spawn 幂等检查加 ps 抓 --project 兜注册表盲区 (已拉起未注册的启动中实例)
- restart/kill 前数预览端口 ESTABLISHED 连接, 有调试现场默认拒绝, force:true 强制
2026-06-11 14:20:17 +08:00

195 lines
7.7 KiB
JavaScript

'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const {
EDITOR_TOOLS,
buildEditorSpawnArgs,
hasReadyAssetResult,
} = require('../src/editor-control.js');
test('editor restart/spawn expose noLogin option', () => {
var names = ['editor_restart', 'editor_spawn'];
names.forEach(function (name) {
var tool = EDITOR_TOOLS.filter(function (t) { return t.name === name; })[0];
assert.ok(tool, 'tool exists: ' + name);
assert.equal(tool.inputSchema.properties.noLogin.type, 'boolean');
});
var waitTool = EDITOR_TOOLS.filter(function (t) { return t.name === 'editor_wait_ready'; })[0];
assert.ok(waitTool, 'tool exists: editor_wait_ready');
assert.equal(waitTool.inputSchema.properties.noLogin, undefined);
});
test('buildEditorSpawnArgs adds --nologin by default', () => {
assert.deepEqual(buildEditorSpawnArgs('/project'), ['--project', '/project', '--nologin']);
});
test('buildEditorSpawnArgs can disable --nologin', () => {
assert.deepEqual(buildEditorSpawnArgs('/project', { noLogin: false }), ['--project', '/project']);
});
test('buildEditorSpawnArgs keeps project path before --nologin', () => {
var args = buildEditorSpawnArgs('/project path', {});
assert.equal(args[0], '--project');
assert.equal(args[1], '/project path');
assert.equal(args[2], '--nologin');
});
test('hasReadyAssetResult accepts raw asset object content', () => {
var result = hasReadyAssetResult({
result: {
content: [
{ name: 'assets', path: 'db://assets/config' },
],
},
});
assert.equal(result, true);
});
test('hasReadyAssetResult accepts text JSON content', () => {
var result = hasReadyAssetResult({
result: {
content: [
{ type: 'text', text: '[{"name":"assets"}]' },
],
},
});
assert.equal(result, true);
});
test('hasReadyAssetResult rejects empty or error results', () => {
assert.equal(hasReadyAssetResult({ result: { content: [] } }), false);
assert.equal(hasReadyAssetResult({ error: { message: 'not ready' } }), false);
assert.equal(hasReadyAssetResult({ result: { isError: true } }), false);
});
// ── 跨进程 spawn 锁 ─────────────────────────────────────────────
const fs = require('node:fs');
const {
spawnLockPath,
acquireSpawnLock,
releaseSpawnLock,
} = require('../src/editor-control.js');
// 用独占的假项目路径,lockfile 名字带 md5 不会撞真实项目
const FAKE_PROJECT = '/tmp/cc-mcp-spawnlock-test-' + process.pid;
test('acquireSpawnLock creates lockfile and releaseSpawnLock removes it', () => {
const lockPath = acquireSpawnLock(FAKE_PROJECT);
assert.ok(fs.existsSync(lockPath), 'lockfile exists after acquire');
const holder = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
assert.equal(holder.pid, process.pid);
assert.equal(holder.projectPath, FAKE_PROJECT);
releaseSpawnLock(FAKE_PROJECT);
assert.ok(!fs.existsSync(lockPath), 'lockfile removed after release');
});
test('acquireSpawnLock is reentrant for the same process', () => {
acquireSpawnLock(FAKE_PROJECT);
assert.doesNotThrow(() => acquireSpawnLock(FAKE_PROJECT));
releaseSpawnLock(FAKE_PROJECT);
});
test('acquireSpawnLock throws when an alive holder owns the lock', () => {
const lockPath = spawnLockPath(FAKE_PROJECT);
// pid 1 = launchd,永远存活且不是本进程
fs.writeFileSync(lockPath, JSON.stringify({ pid: 1, projectPath: FAKE_PROJECT, at: Date.now() }));
assert.throws(() => acquireSpawnLock(FAKE_PROJECT), /正在 spawn\/restart/);
fs.unlinkSync(lockPath);
});
test('acquireSpawnLock steals the lock from a dead holder', () => {
const lockPath = spawnLockPath(FAKE_PROJECT);
// 99999999 超出 pid 上限,必然不存在
fs.writeFileSync(lockPath, JSON.stringify({ pid: 99999999, projectPath: FAKE_PROJECT, at: Date.now() }));
const got = acquireSpawnLock(FAKE_PROJECT);
const holder = JSON.parse(fs.readFileSync(got, 'utf-8'));
assert.equal(holder.pid, process.pid, 'lock stolen by current process');
releaseSpawnLock(FAKE_PROJECT);
});
test('releaseSpawnLock does not remove a lock held by another process', () => {
const lockPath = spawnLockPath(FAKE_PROJECT);
fs.writeFileSync(lockPath, JSON.stringify({ pid: 1, projectPath: FAKE_PROJECT, at: Date.now() }));
releaseSpawnLock(FAKE_PROJECT);
assert.ok(fs.existsSync(lockPath), 'foreign lock untouched');
fs.unlinkSync(lockPath);
});
test('spawnLockPath differs per projectPath (worktree isolation)', () => {
const a = spawnLockPath('/work/forest');
const b = spawnLockPath('/work/forest-wt1/forest');
assert.notEqual(a, b);
});
// ── 调试现场保护(restart/kill 前的预览连接闸门)────────────────
const net = require('node:net');
const os = require('node:os');
const pathMod = require('node:path');
const {
resolvePreviewPort,
countPreviewConnections,
assertNoDebugSession,
} = require('../src/editor-control.js');
test('resolvePreviewPort reads dev-reload-info.json, falls back to 7456', () => {
const dir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'cc-mcp-preview-'));
fs.mkdirSync(pathMod.join(dir, '.dev'));
fs.writeFileSync(pathMod.join(dir, '.dev', 'dev-reload-info.json'), JSON.stringify({ previewPort: 7458 }));
assert.equal(resolvePreviewPort(dir), 7458);
assert.equal(resolvePreviewPort('/no/such/project'), 7456);
fs.rmSync(dir, { recursive: true, force: true });
});
test('countPreviewConnections sees an established connection on own process', async () => {
// 本进程自起 server 自连,lsof -p <自己> 必然看到 ESTABLISHED
const srv = net.createServer(() => {});
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
const port = srv.address().port;
const client = net.connect(port, '127.0.0.1');
await new Promise((r) => client.on('connect', r));
assert.ok(countPreviewConnections(process.pid, port) > 0, 'established connection counted');
client.destroy();
await new Promise((r) => srv.close(r));
// 关掉后不再计数(lsof 偶有延迟,重试两轮)
for (let i = 0; i < 10 && countPreviewConnections(process.pid, port) > 0; i++) {
await new Promise((r) => setTimeout(r, 100));
}
assert.equal(countPreviewConnections(process.pid, port), 0);
});
test('assertNoDebugSession throws on active connections unless forced', async () => {
const dir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'cc-mcp-gate-'));
const srv = net.createServer(() => {});
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
const port = srv.address().port;
fs.mkdirSync(pathMod.join(dir, '.dev'));
fs.writeFileSync(pathMod.join(dir, '.dev', 'dev-reload-info.json'), JSON.stringify({ previewPort: port }));
const client = net.connect(port, '127.0.0.1');
await new Promise((r) => client.on('connect', r));
const target = { pid: process.pid, projectPath: dir };
assert.throws(() => assertNoDebugSession('editor_restart', target, false), /正开着游戏预览/);
assert.doesNotThrow(() => assertNoDebugSession('editor_restart', target, true), 'force bypasses gate');
client.destroy();
await new Promise((r) => srv.close(r));
fs.rmSync(dir, { recursive: true, force: true });
});
test('assertNoDebugSession passes when no connections', () => {
// 没人监听的项目路径 → previewPort 回落 7456,但 pid 用本进程(没连 7456)→ 0 连接
assert.doesNotThrow(() => assertNoDebugSession('editor_kill', { pid: process.pid, projectPath: '/no/such/project' }, false));
});