'use strict'; const assert = require('node:assert/strict'); const fs = require('node:fs'); const http = require('node:http'); const net = require('node:net'); const os = require('node:os'); const path = require('node:path'); const { spawn } = require('node:child_process'); const WebSocket = require('ws'); const ROOT = path.resolve(__dirname, '..'); const SERVER = path.join(ROOT, 'server.js'); function freePort() { return new Promise((resolve, reject) => { const server = net.createServer(); server.once('error', reject); server.listen(0, '127.0.0.1', () => { const port = server.address().port; server.close(() => resolve(port)); }); }); } function waitForPort(port) { const started = Date.now(); return new Promise((resolve, reject) => { const check = () => { const socket = net.createConnection({ host: '127.0.0.1', port }); socket.once('connect', () => { socket.destroy(); resolve(); }); socket.once('error', () => { if (Date.now() - started > 8_000) reject(new Error('等待关闭态测试服务超时')); else setTimeout(check, 40); }); }; check(); }); } function callMcp(port, token, sourceSessionId, method, params = {}) { return new Promise((resolve, reject) => { const body = JSON.stringify({ jsonrpc: '2.0', id: `disabled-${Date.now()}`, method, params }); const request = http.request({ host: '127.0.0.1', port, path: `/api/internal/mcp/stream?sourceSessionId=${encodeURIComponent(sourceSessionId)}`, method: 'POST', headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body), 'x-cc-web-mcp-token': token, }, }, (response) => { let text = ''; response.setEncoding('utf8'); response.on('data', (chunk) => { text += chunk; }); response.on('end', () => resolve({ status: response.statusCode, body: JSON.parse(text || '{}') })); }); request.once('error', reject); request.end(body); }); } function connect(port, password) { return new Promise((resolve, reject) => { const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`); const messages = []; ws.on('open', () => ws.send(JSON.stringify({ type: 'auth', password }))); ws.on('message', (raw) => { const message = JSON.parse(String(raw)); messages.push(message); if (message.type === 'auth_result') { if (message.success) resolve({ ws, messages, auth: message }); else reject(new Error('关闭态测试认证失败')); } }); ws.once('error', reject); }); } function next(messages, predicate) { return new Promise((resolve, reject) => { const started = Date.now(); const timer = setInterval(() => { const index = messages.findIndex(predicate); if (index >= 0) { clearInterval(timer); resolve(messages.splice(index, 1)[0]); } else if (Date.now() - started > 5_000) { clearInterval(timer); reject(new Error(`关闭态 WS 响应超时: ${messages.map((item) => item.type).join(',')}`)); } }, 25); }); } async function main() { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-task-board-disabled-')); const configDir = path.join(root, 'config'); const sessionsDir = path.join(root, 'sessions'); const logsDir = path.join(root, 'logs'); [configDir, sessionsDir, logsDir].forEach((dir) => fs.mkdirSync(dir, { recursive: true })); const sessionId = 'disabled-existing-task'; const sessionPath = path.join(sessionsDir, `${sessionId}.json`); const before = { id: sessionId, title: '重构前历史任务', agent: 'codexapp', cwd: root, created: '2026-08-21T00:00:00.000Z', updated: '2026-08-21T00:00:00.000Z', messages: [], taskTracking: { enabled: true, statusId: 'in_progress', source: 'user', version: 7, archivedAt: '2026-08-20T23:59:00.000Z', archivedBy: 'user', }, }; fs.writeFileSync(sessionPath, JSON.stringify(before, null, 2)); const port = await freePort(); const password = 'Disabled!234'; const token = 'DisabledMcp!234'; const child = spawn(process.execPath, [SERVER], { cwd: ROOT, env: { ...process.env, PORT: String(port), CC_WEB_PASSWORD: password, CC_WEB_INTERNAL_MCP_TOKEN: token, CC_WEB_CONFIG_DIR: configDir, CC_WEB_SESSIONS_DIR: sessionsDir, CC_WEB_LOGS_DIR: logsDir, CC_WEB_TASK_BOARD_ENABLED: '0', HOME: root, CODEX_PATH: path.join(ROOT, 'scripts', 'mock-codex-app-server.js'), }, stdio: ['ignore', 'pipe', 'pipe'], }); try { await waitForPort(port); const html = await (await fetch(`http://127.0.0.1:${port}/`)).text(); assert.match(html, /id="task-board-open"[^>]*hidden/); assert.match(html, /id="task-tracking-control"[^>]*hidden/); const tools = await callMcp(port, token, sessionId, 'tools/list'); assert.equal(tools.status, 200); assert.deepEqual( (tools.body.result?.tools || []).map((tool) => tool.name).filter((name) => name.startsWith('ccweb_task_')), [], ); const update = await callMcp(port, token, sessionId, 'tools/call', { name: 'ccweb_task_update', arguments: { statusId: 'completed' }, }); assert.equal(update.body.result?.structuredContent?.code, 'task_board_disabled'); const client = await connect(port, password); client.ws.send(JSON.stringify({ type: 'task_board_query', requestId: 'disabled-query' })); const query = await next(client.messages, (message) => message.type === 'task_board_result' && message.requestId === 'disabled-query'); assert.equal(query.code, 'task_board_disabled'); client.ws.send(JSON.stringify({ type: 'new_session', requestId: 'disabled-new', taskTrackingEnabled: true })); const created = await next(client.messages, (message) => message.type === 'session_info' && message.requestId === 'disabled-new'); assert.equal(created.taskTracking.enabled, false); client.ws.send(JSON.stringify({ type: 'message', text: '关闭态普通轮次不得触发任务状态 Hook', sessionId, mode: 'yolo', agent: 'codexapp', })); await next(client.messages, (message) => message.type === 'done' && message.sessionId === sessionId); await new Promise((resolve) => setTimeout(resolve, 100)); client.ws.close(); assert.deepEqual(JSON.parse(fs.readFileSync(sessionPath, 'utf8')).taskTracking, before.taskTracking); assert.equal(client.auth.features.taskBoard, false); console.log('Task board disabled checks passed.'); } finally { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await new Promise((resolve) => child.once('exit', resolve)); } } } main().catch((error) => { console.error(error); process.exitCode = 1; });