首次真正连上线上服务器(Debian 12 + pm2 + 端口 78)后暴露的问题,单元测试全绿但机器人干不了活: 致命 - 寻路插件从未加载:项目从没调用 bot.loadPlugin(pathfinder),bot.pathfinder 恒为 undefined、isReady() 恒为 false,扩地任务连启动都启动不了,界面只显示含糊的 「机器人尚未就绪」。现在 createBot 后立刻加载,失败时明确报错。 登录门禁相关(三处死锁) - preLoginCommands 白名单:/login、/register、简幻通的 /auth 本身就是登录流程的一部分, 原先被门禁一并拦下,形成「要先登录才能发登录指令」 - 验证码常被要求直接当聊天内容发出(不是指令),原先被 sendChat 的门禁挡住; 现在服务器明确要求验证时自动放行,另有 server.allowChatBeforeLogin 兜底 - 配置了登录指令却没有密码时 loggedIn 永久为 false,控制台连手动指令都发不出; 现在走与其他「服务器不回提示」一致的超时兜底并给出说明 可用性 - 离线时指令被静默丢弃却回报「已发送」;现在要求已连接并进入世界,失败信息区分 「未连接」「登录被拒」「未通过验证」 - 「被服务器传送」日志洪水:实测 3 分钟 748 行,会冲光日志缓冲并打满 WebSocket; 现在按 30 秒窗口聚合,断开时刷出未汇总计数 - 识别外站账号绑定流程并给出可操作告警,新增 server.authCommand 模板 - 重连指数退避(server.reconnectMaxDelayMs) - 控制台瓦片 URL 与后端路由不一致 新增 - 控制台添加 / 移除玩家:不必改 .env 重启。密码只写入 data/bots.credentials.json (强制 0600),不进仓库、不出现在任何响应里;.env 与凭据文件同名时以 .env 为准, .env 里的账号不允许从控制台移除 - 手动移动测试接口与按钮:绕开寻路器直接按方向键,返回真实位移, 用于区分「算不出路径」与「根本走不动」 - 位置同步诊断:位置包收发计数、onGround、游戏模式、维度、脚下方块与六向实心 - grim.reportOnGroundFromWorld:按世界数据修正出站 onGround(默认开启) - scripts/check-protocol.js:核对包名与字段名同目标版本协议是否一致 文档 - README 新增「部署到服务器」与「机器人走不动的排查方法」两章 已知限制:实测本服上 mineflayer 客户端无法移动(按方向键 2 秒位移 0.00~0.02 格, 服务端每 tick 回弹一次),已排除区块数据、onGround、Grim 层、游戏模式、物理引擎、 寻路插件、teleport_confirm 协议字段等因素,彻底定位需与原生客户端逐字节对比移动包。 连接 / 登录 / 指令 / 聊天 / 公屏日志 / 大地图均正常。 测试从 263 增至 290,全部通过。
484 行
18 KiB
JavaScript
484 行
18 KiB
JavaScript
'use strict';
|
||
|
||
const test = require('node:test');
|
||
const assert = require('node:assert');
|
||
const fs = require('fs');
|
||
const os = require('os');
|
||
const path = require('path');
|
||
|
||
const { WebConsole } = require('../src/web/server');
|
||
const { BotPool } = require('../src/bot/botPool');
|
||
const { BotStore, BotCredentialStore } = require('../src/config/botStore');
|
||
const { ExpansionLog } = require('../src/core/expansionLog');
|
||
const { ChatLog, ChatKind } = require('../src/core/chatLog');
|
||
const { BigMapService } = require('../src/core/bigMapService');
|
||
const { TerritoryModel } = require('../src/core/territoryModel');
|
||
const { defaultConfig, normalizeConfig } = require('../src/config/expansionConfig');
|
||
const { Logger } = require('../src/util/logger');
|
||
const chunkPos = require('../src/core/chunkPos');
|
||
|
||
/** 造一个含 2 个机器人、完整依赖的控制台 */
|
||
function setup(options) {
|
||
const opts = options || {};
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-webmulti-'));
|
||
|
||
const map = new Map([
|
||
['A', {
|
||
name: 'A',
|
||
capital: { x: 1, z: 2 },
|
||
chunks: new Set([chunkPos.key(0, 0), chunkPos.key(1, 0)]),
|
||
color: 0x2f81f7,
|
||
polygons: [[0, 0, 16, 0, 16, 16, 0, 16]]
|
||
}],
|
||
['B', {
|
||
name: 'B',
|
||
capital: { x: 9, z: 9 },
|
||
chunks: new Set([chunkPos.key(9, 9)]),
|
||
color: 0xff43de,
|
||
polygons: []
|
||
}]
|
||
]);
|
||
const model = new TerritoryModel(map);
|
||
model.setSelfStateName('A');
|
||
model.addSessionClaim({ x: 5, z: 5 }, 'A');
|
||
|
||
const dataService = {
|
||
model,
|
||
getModel: () => model,
|
||
snapshot: () => ({ stateCount: 2, claimedChunks: 4, markersUrl: 'http://x/markers.json' }),
|
||
refreshAsync: () => {},
|
||
fetchAllTerritoryOnce: async () => ({ ok: true, message: 'ok' }),
|
||
detectStateByPlayer: async () => null,
|
||
setSelectedStateName: (name) => model.setSelfStateName(name),
|
||
addSessionClaim: (chunk, name) => model.addSessionClaim(chunk, name),
|
||
applyApiConfig: () => {},
|
||
reschedule: () => {},
|
||
start: () => {},
|
||
stop: () => {}
|
||
};
|
||
|
||
const config = normalizeConfig(Object.assign(defaultConfig(), opts.config || {}));
|
||
const logger = new Logger({ echo: false });
|
||
const expansionLog = new ExpansionLog({ filePath: path.join(dir, 'expansion-log.jsonl') });
|
||
const chatLog = new ChatLog({ dir: path.join(dir, 'chat') });
|
||
const bigMap = new BigMapService(config, logger, {
|
||
baseUrl: 'http://bgjq.simpfun.cn',
|
||
world: 'minecraft_overworld',
|
||
cacheDir: path.join(dir, 'mapcache'),
|
||
fetchImpl: async () => ({
|
||
ok: true,
|
||
status: 200,
|
||
arrayBuffer: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer
|
||
})
|
||
});
|
||
const pool = new BotPool({
|
||
connections: [
|
||
{ id: 'BotA', username: 'BotA', password: 'pw', host: 'h', port: 25565, version: '1.21.4' },
|
||
{ id: 'BotB', username: 'BotB', password: 'pw', host: 'h', port: 25565, version: '1.21.4' }
|
||
],
|
||
config,
|
||
logger,
|
||
dataService,
|
||
expansionLog,
|
||
chatLog,
|
||
botStore: new BotStore(path.join(dir, 'bots.json')),
|
||
credentialStore: new BotCredentialStore(path.join(dir, 'bots.credentials.json'))
|
||
});
|
||
|
||
const webConsole = new WebConsole({
|
||
pool,
|
||
dataService,
|
||
logger,
|
||
expansionLog,
|
||
chatLog,
|
||
bigMap,
|
||
getConfig: () => config,
|
||
applyConfig: (next) => Object.assign(config, next)
|
||
}, { host: '127.0.0.1', port: 0, exportDir: dir, password: opts.password });
|
||
|
||
return { webConsole, pool, model, chatLog, expansionLog, bigMap, dir, config, logger };
|
||
}
|
||
|
||
test('statusPayload 汇总全部机器人与新增日志 / 大地图状态', () => {
|
||
const { webConsole, pool } = setup();
|
||
const payload = webConsole.statusPayload();
|
||
assert.strictEqual(payload.bots.length, 2);
|
||
assert.strictEqual(payload.bot.id, 'BotA', '单机器人兼容字段指向默认机器人');
|
||
assert.strictEqual(payload.task.state, 'IDLE');
|
||
assert.strictEqual(payload.defaultBotId, 'BotA');
|
||
assert.ok(payload.expansionLog);
|
||
assert.ok(payload.chatLog);
|
||
assert.ok(payload.bigMap);
|
||
assert.strictEqual(payload.bigMap.tiles.count, 100);
|
||
assert.strictEqual(pool.size, 2);
|
||
});
|
||
|
||
test('HTTP:/api/bots 返回机器人列表,/api/bots/:id/status 返回单个机器人', async () => {
|
||
const { webConsole } = setup();
|
||
await webConsole.start();
|
||
const base = webConsole.baseUrl();
|
||
try {
|
||
const bots = await fetch(base + '/api/bots').then((r) => r.json());
|
||
assert.strictEqual(bots.ok, true);
|
||
assert.deepStrictEqual(bots.data.map((b) => b.id), ['BotA', 'BotB']);
|
||
|
||
const single = await fetch(base + '/api/bots/BotB/status').then((r) => r.json());
|
||
assert.strictEqual(single.data.id, 'BotB');
|
||
assert.strictEqual(single.data.task.botId, 'BotB');
|
||
|
||
const missing = await fetch(base + '/api/bots/没有这个/status');
|
||
assert.strictEqual(missing.status, 404);
|
||
} finally {
|
||
await webConsole.stop();
|
||
}
|
||
});
|
||
|
||
test('HTTP:机器人设置可读可写,且不会泄露密码', async () => {
|
||
const { webConsole } = setup();
|
||
await webConsole.start();
|
||
const base = webConsole.baseUrl();
|
||
try {
|
||
const saved = await fetch(base + '/api/bots/BotB/settings', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ label: '二号机', enabled: true, autoStart: false, config: { targetCount: 6 } })
|
||
}).then((r) => r.json());
|
||
assert.strictEqual(saved.ok, true);
|
||
assert.strictEqual(saved.data.label, '二号机');
|
||
assert.strictEqual(saved.data.config.targetCount, 6);
|
||
assert.ok(!JSON.stringify(saved.data).includes('pw'), '响应中不得含密码');
|
||
|
||
const read = await fetch(base + '/api/bots/BotB/settings').then((r) => r.json());
|
||
assert.strictEqual(read.data.label, '二号机');
|
||
} finally {
|
||
await webConsole.stop();
|
||
}
|
||
});
|
||
|
||
test('HTTP:机器人未就绪时启动任务返回 ok=false 并给出原因', async () => {
|
||
const { webConsole } = setup();
|
||
await webConsole.start();
|
||
const base = webConsole.baseUrl();
|
||
try {
|
||
const res = await fetch(base + '/api/bots/BotA/task/start', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({})
|
||
}).then((r) => r.json());
|
||
assert.strictEqual(res.ok, false);
|
||
assert.ok(res.message.includes('尚未就绪'));
|
||
} finally {
|
||
await webConsole.stop();
|
||
}
|
||
});
|
||
|
||
test('HTTP:聊天接口在未完成登录验证时返回 409,缺参数返回 400', async () => {
|
||
const { webConsole, pool } = setup();
|
||
await webConsole.start();
|
||
const base = webConsole.baseUrl();
|
||
try {
|
||
const missing = await fetch(base + '/api/bots/BotA/chat', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({})
|
||
});
|
||
assert.strictEqual(missing.status, 400);
|
||
|
||
// 未连接:提示应指向「没连上」而不是含糊的失败
|
||
const offline = await fetch(base + '/api/bots/BotA/chat', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ message: '大家好' })
|
||
});
|
||
assert.strictEqual(offline.status, 409);
|
||
const offlineBody = await offline.json();
|
||
assert.ok(offlineBody.message.includes('未连接'), '离线时应说明未连接');
|
||
|
||
// 已连接但未通过登录验证:提示应指向登录门禁
|
||
const manager = pool.get('BotA').manager;
|
||
manager.state = 'online';
|
||
manager.bot = { chat: () => {} };
|
||
manager.loggedIn = false;
|
||
const denied = await fetch(base + '/api/bots/BotA/chat', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ message: '大家好' })
|
||
});
|
||
assert.strictEqual(denied.status, 409);
|
||
const body = await denied.json();
|
||
assert.ok(body.message.includes('未完成登录验证'));
|
||
} finally {
|
||
await webConsole.stop();
|
||
}
|
||
});
|
||
|
||
test('HTTP:指令接口区分「未连接」与「未通过登录验证」,并放行验证类指令', async () => {
|
||
const { webConsole, pool } = setup();
|
||
await webConsole.start();
|
||
const base = webConsole.baseUrl();
|
||
const post = (command) => fetch(base + '/api/bots/BotA/command', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ command })
|
||
});
|
||
try {
|
||
const offline = await post('u claimmode');
|
||
assert.strictEqual(offline.status, 409);
|
||
assert.ok((await offline.json()).message.includes('未连接'));
|
||
|
||
const manager = pool.get('BotA').manager;
|
||
const sent = [];
|
||
manager.bot = { chat: (text) => sent.push(text) };
|
||
manager.state = 'online';
|
||
manager.loggedIn = false;
|
||
|
||
// 任务类指令仍被拦下
|
||
const blocked = await post('u claimmode');
|
||
assert.strictEqual(blocked.status, 409);
|
||
assert.ok((await blocked.json()).message.includes('未完成登录验证'));
|
||
|
||
// 验证类指令放行(否则无法完成登录,形成死锁)
|
||
const allowed = await post('/auth 12345 6789');
|
||
assert.strictEqual(allowed.status, 200);
|
||
assert.strictEqual((await allowed.json()).ok, true);
|
||
} finally {
|
||
await webConsole.stop();
|
||
}
|
||
});
|
||
|
||
test('HTTP:公屏日志可按机器人查询与导出', async () => {
|
||
const { webConsole, chatLog } = setup();
|
||
chatLog.append({ botId: 'BotA', bot: 'BotA', message: '你好', kind: ChatKind.CHAT, username: 'Steve' });
|
||
chatLog.append({ botId: 'BotB', bot: 'BotB', message: '别的机器人的消息' });
|
||
await webConsole.start();
|
||
const base = webConsole.baseUrl();
|
||
try {
|
||
const rows = await fetch(base + '/api/bots/BotA/chat').then((r) => r.json());
|
||
assert.strictEqual(rows.data.length, 1);
|
||
assert.strictEqual(rows.data[0].message, '你好');
|
||
|
||
const exported = await fetch(base + '/api/bots/BotA/chat/export', { method: 'POST' }).then((r) => r.json());
|
||
assert.strictEqual(exported.ok, true);
|
||
assert.ok(fs.existsSync(exported.data.file));
|
||
} finally {
|
||
await webConsole.stop();
|
||
}
|
||
});
|
||
|
||
test('HTTP:开拓日志查询与 CSV 导出', async () => {
|
||
const { webConsole, expansionLog } = setup();
|
||
expansionLog.record({
|
||
botId: 'BotA',
|
||
bot: 'BotA',
|
||
state: 'A',
|
||
chunk: { x: 3, z: 4 },
|
||
block: { x: 48, z: 64 },
|
||
source: 'chat'
|
||
});
|
||
expansionLog.record({ botId: 'BotB', bot: 'BotB', state: 'B', chunk: { x: 9, z: 9 } });
|
||
await webConsole.start();
|
||
const base = webConsole.baseUrl();
|
||
try {
|
||
const all = await fetch(base + '/api/expansion-log').then((r) => r.json());
|
||
assert.strictEqual(all.data.entries.length, 2);
|
||
assert.strictEqual(all.data.stats.total, 2);
|
||
assert.strictEqual(all.data.stats.byBot.length, 2);
|
||
|
||
const filtered = await fetch(base + '/api/expansion-log?botId=BotA').then((r) => r.json());
|
||
assert.strictEqual(filtered.data.entries.length, 1);
|
||
assert.strictEqual(filtered.data.entries[0].cx, 3);
|
||
|
||
const exported = await fetch(base + '/api/expansion-log/export', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({})
|
||
}).then((r) => r.json());
|
||
assert.strictEqual(exported.ok, true);
|
||
const text = fs.readFileSync(exported.data.file, 'utf8');
|
||
assert.ok(text.includes('机器人'), 'CSV 应含表头');
|
||
assert.ok(text.includes('BotA'));
|
||
} finally {
|
||
await webConsole.stop();
|
||
}
|
||
});
|
||
|
||
test('HTTP:大地图元信息、领土标记与瓦片代理', async () => {
|
||
const { webConsole } = setup();
|
||
await webConsole.start();
|
||
const base = webConsole.baseUrl();
|
||
try {
|
||
const meta = await fetch(base + '/api/bigmap').then((r) => r.json());
|
||
assert.strictEqual(meta.data.world, 'minecraft_overworld');
|
||
assert.strictEqual(meta.data.tilePixels, 512);
|
||
assert.strictEqual(meta.data.tiles.count, 100);
|
||
assert.strictEqual(meta.data.bots.length, 2, '大地图应带上全部机器人坐标');
|
||
|
||
const markers = await fetch(base + '/api/bigmap/markers').then((r) => r.json());
|
||
const a = markers.data.states.find((s) => s.name === 'A');
|
||
assert.strictEqual(a.color, '#2f81f7');
|
||
assert.strictEqual(a.polygons.length, 1);
|
||
assert.deepStrictEqual(a.polygons[0], [0, 0, 16, 0, 16, 16, 0, 16]);
|
||
|
||
const tile = await fetch(base + '/api/bigmap/tile/0/0/0.png');
|
||
assert.strictEqual(tile.status, 200);
|
||
assert.strictEqual(tile.headers.get('content-type'), 'image/png');
|
||
|
||
const badTile = await fetch(base + '/api/bigmap/tile/x/0/0.png');
|
||
assert.strictEqual(badTile.status, 400);
|
||
} finally {
|
||
await webConsole.stop();
|
||
}
|
||
});
|
||
|
||
test('HTTP:地图数据可按机器人视角返回各自的邦国与目标', async () => {
|
||
const { webConsole, pool } = setup();
|
||
pool.get('BotB').controller.stateName = 'B';
|
||
await webConsole.start();
|
||
const base = webConsole.baseUrl();
|
||
try {
|
||
const a = await fetch(base + '/api/map?bot=BotA').then((r) => r.json());
|
||
assert.strictEqual(a.data.selfState, 'A');
|
||
// 邦国 A 原有 2 个区块 + 1 个会话占领区块 = 3 个区块 → 6 个数字
|
||
assert.strictEqual(a.data.self.length, 6);
|
||
|
||
const b = await fetch(base + '/api/map?bot=BotB').then((r) => r.json());
|
||
assert.strictEqual(b.data.selfState, 'B');
|
||
assert.strictEqual(b.data.self.length, 2);
|
||
} finally {
|
||
await webConsole.stop();
|
||
}
|
||
});
|
||
|
||
test('HTTP:配置了控制台密码时未登录一律 401,登录后可访问', async () => {
|
||
const { webConsole } = setup({ password: 'console-pw' });
|
||
await webConsole.start();
|
||
const base = webConsole.baseUrl();
|
||
try {
|
||
// 会话探测接口本身免鉴权,用于前端决定是否弹登录框
|
||
const session = await fetch(base + '/api/session').then((r) => r.json());
|
||
assert.strictEqual(session.data.required, true);
|
||
assert.strictEqual(session.data.authenticated, false);
|
||
|
||
const denied = await fetch(base + '/api/bots');
|
||
assert.strictEqual(denied.status, 401);
|
||
|
||
const wrong = await fetch(base + '/api/login', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ password: '错的' })
|
||
});
|
||
assert.strictEqual(wrong.status, 401);
|
||
|
||
const login = await fetch(base + '/api/login', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ password: 'console-pw' })
|
||
}).then((r) => r.json());
|
||
assert.strictEqual(login.ok, true);
|
||
assert.strictEqual(login.data.authenticated, true);
|
||
|
||
const allowed = await fetch(base + '/api/bots', { headers: { 'X-Auth-Token': 'console-pw' } });
|
||
assert.strictEqual(allowed.status, 200);
|
||
|
||
// 静态页面本身仍需可加载,否则用户看不到登录框
|
||
const html = await fetch(base + '/');
|
||
assert.strictEqual(html.status, 200);
|
||
} finally {
|
||
await webConsole.stop();
|
||
}
|
||
});
|
||
|
||
test('HTTP:可在控制台新增与移除玩家,响应中不含密码', async () => {
|
||
const { webConsole, pool } = setup();
|
||
// 避免测试真的发起网络连接
|
||
const proto = Object.getPrototypeOf(pool.get('BotA').manager);
|
||
const originalConnect = proto.connect;
|
||
proto.connect = function stub() {};
|
||
await webConsole.start();
|
||
const base = webConsole.baseUrl();
|
||
try {
|
||
const added = await fetch(base + '/api/bots', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ username: 'NewBot', password: 'super-secret', label: '新号' })
|
||
}).then((r) => r.json());
|
||
assert.strictEqual(added.ok, true);
|
||
assert.strictEqual(added.data.id, 'NewBot');
|
||
assert.ok(!JSON.stringify(added).includes('super-secret'), '响应中不得回显密码');
|
||
assert.deepStrictEqual(added.data.bots.map((b) => b.id), ['BotA', 'BotB', 'NewBot']);
|
||
assert.strictEqual(added.data.bots[2].source, 'store');
|
||
|
||
const duplicate = await fetch(base + '/api/bots', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ username: 'NewBot' })
|
||
});
|
||
assert.strictEqual(duplicate.status, 400);
|
||
|
||
const invalid = await fetch(base + '/api/bots', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ username: '带中文的名字' })
|
||
});
|
||
assert.strictEqual(invalid.status, 400);
|
||
|
||
// .env 里配置的账号不允许从控制台移除
|
||
const envBot = await fetch(base + '/api/bots/BotA', { method: 'DELETE' });
|
||
assert.strictEqual(envBot.status, 400);
|
||
|
||
const removed = await fetch(base + '/api/bots/NewBot', { method: 'DELETE' }).then((r) => r.json());
|
||
assert.strictEqual(removed.ok, true);
|
||
assert.deepStrictEqual(removed.data.bots.map((b) => b.id), ['BotA', 'BotB']);
|
||
} finally {
|
||
proto.connect = originalConnect;
|
||
await webConsole.stop();
|
||
}
|
||
});
|
||
|
||
test('HTTP:未配置密码时免登录(本机开发模式)', async () => {
|
||
const { webConsole } = setup();
|
||
await webConsole.start();
|
||
const base = webConsole.baseUrl();
|
||
try {
|
||
const session = await fetch(base + '/api/session').then((r) => r.json());
|
||
assert.strictEqual(session.data.required, false);
|
||
assert.strictEqual(session.data.authenticated, true);
|
||
const bots = await fetch(base + '/api/bots');
|
||
assert.strictEqual(bots.status, 200);
|
||
} finally {
|
||
await webConsole.stop();
|
||
}
|
||
});
|
||
|
||
test('WebSocket:握手鉴权失败时被拒绝,成功后可收到状态推送', async () => {
|
||
const { webConsole } = setup({ password: 'console-pw' });
|
||
await webConsole.start();
|
||
const base = webConsole.baseUrl();
|
||
const WebSocket = require('ws');
|
||
try {
|
||
const rejected = await new Promise((resolve) => {
|
||
const socket = new WebSocket(base.replace('http', 'ws') + '/ws');
|
||
socket.on('error', () => resolve('error'));
|
||
socket.on('open', () => { socket.close(); resolve('open'); });
|
||
});
|
||
assert.strictEqual(rejected, 'error');
|
||
|
||
const payload = await new Promise((resolve, reject) => {
|
||
const socket = new WebSocket(base.replace('http', 'ws') + '/ws?token=console-pw');
|
||
const timer = setTimeout(() => reject(new Error('未收到状态推送')), 3000);
|
||
socket.on('message', (data) => {
|
||
const parsed = JSON.parse(data.toString());
|
||
if (parsed.type === 'status') {
|
||
clearTimeout(timer);
|
||
socket.close();
|
||
resolve(parsed.data);
|
||
}
|
||
});
|
||
socket.on('error', reject);
|
||
});
|
||
assert.strictEqual(payload.bots.length, 2);
|
||
} finally {
|
||
await webConsole.stop();
|
||
}
|
||
});
|