index.test.js
1 'use strict' 2 3 const test = require('node:test') 4 const assert = require('node:assert/strict') 5 const fs = require('fs') 6 const os = require('os') 7 const path = require('path') 8 9 const { 10 COMMANDS, 11 extractPathParams, 12 getApiCoveragePairs, 13 parseArgv, 14 runCli, 15 } = require('./index') 16 17 function collectApiRoutePairs() { 18 const root = path.join(process.cwd(), 'src', 'app', 'api') 19 const files = [] 20 21 function walk(dir) { 22 const entries = fs.readdirSync(dir, { withFileTypes: true }) 23 for (const entry of entries) { 24 const full = path.join(dir, entry.name) 25 if (entry.isDirectory()) walk(full) 26 else if (entry.isFile() && entry.name === 'route.ts') files.push(full) 27 } 28 } 29 30 walk(root) 31 32 const pairs = new Set() 33 for (const filePath of files) { 34 const text = fs.readFileSync(filePath, 'utf8') 35 const rel = filePath 36 .replace(path.join(process.cwd(), 'src', 'app', 'api'), '') 37 .replace(/\\/g, '/') 38 .replace(/\/route\.ts$/, '') 39 const route = (rel || '/').replace(/\[(.+?)\]/g, ':$1') 40 41 const methods = [...text.matchAll(/export async function (GET|POST|PUT|PATCH|DELETE)/g)] 42 .map((match) => match[1]) 43 44 for (const method of methods) { 45 pairs.add(`${method} ${route}`) 46 } 47 } 48 49 return pairs 50 } 51 52 function makeWritable() { 53 return { 54 chunks: [], 55 isTTY: false, 56 write(chunk) { 57 this.chunks.push(Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)) 58 return true 59 }, 60 toString() { 61 return this.chunks.join('') 62 }, 63 } 64 } 65 66 function jsonResponse(value, status = 200, headers = {}) { 67 return new Response(JSON.stringify(value), { 68 status, 69 headers: { 70 'content-type': 'application/json', 71 ...headers, 72 }, 73 }) 74 } 75 76 test('CLI command map covers all API route method/path pairs', () => { 77 const routePairs = collectApiRoutePairs() 78 const commandPairs = new Set(getApiCoveragePairs()) 79 80 const missing = [...routePairs].filter((pair) => !commandPairs.has(pair)).sort() 81 assert.deepEqual(missing, []) 82 }) 83 84 test('Binary CLI router reaches every mapped API command pair', async () => { 85 const { shouldUseLegacyTsCli, TS_CLI_ACTIONS } = await import('../../bin/swarmclaw.js') 86 87 for (const command of COMMANDS) { 88 if (command.virtual) continue 89 90 const pathArgs = extractPathParams(command.route).map((name, index) => `${name}-${index + 1}`) 91 const routedToLegacyTs = shouldUseLegacyTsCli([command.group, command.action, ...pathArgs]) 92 93 if (routedToLegacyTs) { 94 assert.ok( 95 TS_CLI_ACTIONS[command.group]?.has(command.action), 96 `legacy TS router should only claim known actions (${command.group} ${command.action})`, 97 ) 98 } 99 } 100 101 // Spot-check known API commands that are map-only today. 102 assert.equal(shouldUseLegacyTsCli(['chatrooms', 'list']), false) 103 assert.equal(shouldUseLegacyTsCli(['tasks', 'approve', 'task-1']), false) 104 105 // Help paths should route to mapped CLI for full command discoverability. 106 assert.equal(shouldUseLegacyTsCli([]), false) 107 assert.equal(shouldUseLegacyTsCli(['--help']), false) 108 assert.equal(shouldUseLegacyTsCli(['tasks', '--help']), false) 109 110 // And a legacy command that should remain on the richer TS path. 111 assert.equal(shouldUseLegacyTsCli(['tasks', 'create']), true) 112 }) 113 114 test('parseArgv parses group/action/options', () => { 115 const parsed = parseArgv([ 116 'runs', 117 'list', 118 '--query', 119 'sessionId=abc123', 120 '--query=limit=25', 121 '--base-url', 122 'http://localhost:3456', 123 '--json', 124 '--wait', 125 ]) 126 127 assert.equal(parsed.group, 'runs') 128 assert.equal(parsed.action, 'list') 129 assert.deepEqual(parsed.opts.query, ['sessionId=abc123', 'limit=25']) 130 assert.equal(parsed.opts.baseUrl, 'http://localhost:3456') 131 assert.equal(parsed.opts.jsonOutput, true) 132 assert.equal(parsed.opts.wait, true) 133 }) 134 135 test('runCli sends authenticated request and emits compact JSON when --json is set', async () => { 136 const stdout = makeWritable() 137 const stderr = makeWritable() 138 const calls = [] 139 140 const fetchImpl = async (url, init) => { 141 calls.push({ url: String(url), init }) 142 return jsonResponse({ ok: true }) 143 } 144 145 const exitCode = await runCli( 146 ['runs', 'list', '--query', 'sessionId=main-wayde', '--json'], 147 { 148 fetchImpl, 149 stdout, 150 stderr, 151 env: { 152 SWARMCLAW_API_KEY: 'test-key', 153 }, 154 cwd: process.cwd(), 155 } 156 ) 157 158 assert.equal(exitCode, 0) 159 assert.equal(calls.length, 1) 160 assert.match(calls[0].url, /\/api\/runs\?sessionId=main-wayde$/) 161 assert.equal(calls[0].init.headers['X-Access-Key'], 'test-key') 162 assert.equal(stdout.toString().trim(), '{"ok":true}') 163 assert.equal(stderr.toString(), '') 164 }) 165 166 test('openclaw deploy bundle command merges action with provided JSON body', async () => { 167 const stdout = makeWritable() 168 const stderr = makeWritable() 169 const calls = [] 170 171 const fetchImpl = async (url, init) => { 172 calls.push({ url: String(url), init }) 173 return jsonResponse({ ok: true, bundle: { template: 'docker' } }) 174 } 175 176 const exitCode = await runCli( 177 ['openclaw', 'deploy-bundle', '--data', '{"template":"docker","target":"openclaw.example.com"}', '--json'], 178 { 179 fetchImpl, 180 stdout, 181 stderr, 182 env: {}, 183 cwd: process.cwd(), 184 } 185 ) 186 187 assert.equal(exitCode, 0) 188 assert.equal(calls.length, 1) 189 assert.match(calls[0].url, /\/api\/openclaw\/deploy$/) 190 assert.equal(calls[0].init.method, 'POST') 191 assert.deepEqual(JSON.parse(String(calls[0].init.body)), { 192 action: 'bundle', 193 template: 'docker', 194 target: 'openclaw.example.com', 195 }) 196 assert.equal(stdout.toString().trim(), '{"ok":true,"bundle":{"template":"docker"}}') 197 assert.equal(stderr.toString(), '') 198 }) 199 200 test('openclaw deploy ssh command merges action with provided JSON body', async () => { 201 const stdout = makeWritable() 202 const stderr = makeWritable() 203 const calls = [] 204 205 const fetchImpl = async (url, init) => { 206 calls.push({ url: String(url), init }) 207 return jsonResponse({ ok: true, processId: 'remote-1' }) 208 } 209 210 const exitCode = await runCli( 211 ['openclaw', 'deploy-ssh', '--data', '{"target":"openclaw.example.com","ssh":{"host":"1.2.3.4"}}', '--json'], 212 { 213 fetchImpl, 214 stdout, 215 stderr, 216 env: {}, 217 cwd: process.cwd(), 218 } 219 ) 220 221 assert.equal(exitCode, 0) 222 assert.equal(calls.length, 1) 223 assert.match(calls[0].url, /\/api\/openclaw\/deploy$/) 224 assert.equal(calls[0].init.method, 'POST') 225 assert.deepEqual(JSON.parse(String(calls[0].init.body)), { 226 action: 'ssh-deploy', 227 target: 'openclaw.example.com', 228 ssh: { host: '1.2.3.4' }, 229 }) 230 assert.equal(stdout.toString().trim(), '{"ok":true,"processId":"remote-1"}') 231 assert.equal(stderr.toString(), '') 232 }) 233 234 test('openclaw remote restore command merges action with provided JSON body', async () => { 235 const stdout = makeWritable() 236 const stderr = makeWritable() 237 const calls = [] 238 239 const fetchImpl = async (url, init) => { 240 calls.push({ url: String(url), init }) 241 return jsonResponse({ ok: true, remote: { status: 'running' } }) 242 } 243 244 const exitCode = await runCli( 245 ['openclaw', 'remote-restore', '--data', '{"backupPath":"/opt/openclaw/backups/latest.tgz","ssh":{"host":"1.2.3.4"}}', '--json'], 246 { 247 fetchImpl, 248 stdout, 249 stderr, 250 env: {}, 251 cwd: process.cwd(), 252 } 253 ) 254 255 assert.equal(exitCode, 0) 256 assert.equal(calls.length, 1) 257 assert.match(calls[0].url, /\/api\/openclaw\/deploy$/) 258 assert.equal(calls[0].init.method, 'POST') 259 assert.deepEqual(JSON.parse(String(calls[0].init.body)), { 260 action: 'remote-restore', 261 backupPath: '/opt/openclaw/backups/latest.tgz', 262 ssh: { host: '1.2.3.4' }, 263 }) 264 assert.equal(stdout.toString().trim(), '{"ok":true,"remote":{"status":"running"}}') 265 assert.equal(stderr.toString(), '') 266 }) 267 268 test('runCli falls back to platform-api-key.txt when env key is missing', async () => { 269 const stdout = makeWritable() 270 const stderr = makeWritable() 271 const calls = [] 272 273 const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-cli-keyfile-')) 274 fs.writeFileSync(path.join(tmpDir, 'platform-api-key.txt'), 'file-key\n', 'utf8') 275 276 const fetchImpl = async (url, init) => { 277 calls.push({ url: String(url), init }) 278 return jsonResponse({ ok: true }) 279 } 280 281 const exitCode = await runCli( 282 ['runs', 'list', '--json'], 283 { 284 fetchImpl, 285 stdout, 286 stderr, 287 env: {}, 288 cwd: tmpDir, 289 } 290 ) 291 292 assert.equal(exitCode, 0) 293 assert.equal(calls[0].init.headers['X-Access-Key'], 'file-key') 294 assert.equal(stderr.toString(), '') 295 296 fs.rmSync(tmpDir, { recursive: true, force: true }) 297 }) 298 299 test('upload command sends binary body and x-filename header', async () => { 300 const stdout = makeWritable() 301 const stderr = makeWritable() 302 303 const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-cli-')) 304 const filePath = path.join(tmpDir, 'sample.txt') 305 fs.writeFileSync(filePath, 'hello upload', 'utf8') 306 307 const calls = [] 308 const fetchImpl = async (url, init) => { 309 calls.push({ url: String(url), init }) 310 return jsonResponse({ ok: true, url: '/api/uploads/example.txt' }) 311 } 312 313 const exitCode = await runCli( 314 ['upload', 'file', filePath], 315 { 316 fetchImpl, 317 stdout, 318 stderr, 319 env: {}, 320 cwd: process.cwd(), 321 } 322 ) 323 324 assert.equal(exitCode, 0) 325 assert.equal(calls.length, 1) 326 assert.match(calls[0].url, /\/api\/upload$/) 327 assert.ok(Buffer.isBuffer(calls[0].init.body)) 328 assert.equal(calls[0].init.headers['x-filename'], 'sample.txt') 329 330 fs.rmSync(tmpDir, { recursive: true, force: true }) 331 }) 332 333 test('binary responses require --out when stdout is a TTY', async () => { 334 const stdout = makeWritable() 335 stdout.isTTY = true 336 const stderr = makeWritable() 337 338 const fetchImpl = async () => 339 new Response(Buffer.from('hello'), { 340 status: 200, 341 headers: { 'content-type': 'application/octet-stream' }, 342 }) 343 344 const exitCode = await runCli( 345 ['uploads', 'get', 'artifact.bin'], 346 { 347 fetchImpl, 348 stdout, 349 stderr, 350 env: {}, 351 cwd: process.cwd(), 352 } 353 ) 354 355 assert.equal(exitCode, 1) 356 assert.match(stderr.toString(), /binary response requires --out <file>/i) 357 }) 358 359 test('wait polls run endpoint until terminal state', async () => { 360 const stdout = makeWritable() 361 const stderr = makeWritable() 362 let runPollCount = 0 363 364 const fetchImpl = async (url, init) => { 365 const u = String(url) 366 if (u.endsWith('/api/webhooks/hook-1')) { 367 return jsonResponse({ ok: true, runId: 'run_1' }) 368 } 369 if (u.endsWith('/api/runs/run_1')) { 370 runPollCount += 1 371 if (runPollCount < 2) { 372 return jsonResponse({ id: 'run_1', status: 'queued' }) 373 } 374 return jsonResponse({ id: 'run_1', status: 'completed' }) 375 } 376 return jsonResponse({ error: 'unexpected url', url: u }, 500) 377 } 378 379 const exitCode = await runCli( 380 ['webhooks', 'trigger', 'hook-1', '--data', '{}', '--wait', '--interval-ms', '1', '--timeout-ms', '2000'], 381 { 382 fetchImpl, 383 stdout, 384 stderr, 385 env: {}, 386 cwd: process.cwd(), 387 } 388 ) 389 390 assert.equal(exitCode, 0) 391 assert.ok(runPollCount >= 2) 392 assert.equal(stderr.toString(), '') 393 assert.match(stdout.toString(), /"runId": "run_1"/) 394 assert.match(stdout.toString(), /\[wait\] run run_1: queued/) 395 assert.match(stdout.toString(), /"status": "completed"/) 396 }) 397 398 test('runCli parses CRLF-delimited SSE events correctly', async () => { 399 const stdout = makeWritable() 400 const stderr = makeWritable() 401 402 const fetchImpl = async () => new Response( 403 'data: {"t":"md","text":"first"}\r\n\r\ndata: {"t":"md","text":"second"}\r\n\r\n', 404 { 405 status: 200, 406 headers: { 'content-type': 'text/event-stream' }, 407 } 408 ) 409 410 const exitCode = await runCli( 411 ['chatrooms', 'chat', 'room-1', '--data', '{}'], 412 { 413 fetchImpl, 414 stdout, 415 stderr, 416 env: {}, 417 cwd: process.cwd(), 418 } 419 ) 420 421 assert.equal(exitCode, 0) 422 assert.equal(stdout.toString(), 'first\nsecond\n') 423 assert.equal(stderr.toString(), '') 424 }) 425 426 test('binary responses require --out when stdout is a TTY', async () => { 427 const stdout = makeWritable() 428 stdout.isTTY = true 429 const stderr = makeWritable() 430 431 const fetchImpl = async () => new Response(Buffer.from('ok'), { 432 status: 200, 433 headers: { 'content-type': 'application/octet-stream' }, 434 }) 435 436 const exitCode = await runCli( 437 ['memory-images', 'get', 'image-1.png'], 438 { 439 fetchImpl, 440 stdout, 441 stderr, 442 env: {}, 443 cwd: process.cwd(), 444 } 445 ) 446 447 assert.equal(exitCode, 1) 448 assert.equal(stdout.toString(), '') 449 assert.match(stderr.toString(), /binary response requires --out <file>/i) 450 }) 451 452 test('client-side collection lookups fail cleanly when the entity is missing', async () => { 453 const stdout = makeWritable() 454 const stderr = makeWritable() 455 456 const fetchImpl = async () => new Response(JSON.stringify({ error: 'Not found' }), { 457 status: 404, 458 headers: { 'content-type': 'application/json' }, 459 }) 460 461 const exitCode = await runCli( 462 ['agents', 'get', 'agent-1'], 463 { 464 fetchImpl, 465 stdout, 466 stderr, 467 env: {}, 468 cwd: process.cwd(), 469 } 470 ) 471 472 assert.equal(exitCode, 1) 473 assert.equal(stdout.toString(), '') 474 assert.match(stderr.toString(), /not found/i) 475 }) 476 477 test('runCli loads request JSON from @file inputs', async () => { 478 const stdout = makeWritable() 479 const stderr = makeWritable() 480 const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-cli-data-')) 481 const dataPath = path.join(tmpDir, 'payload.json') 482 fs.writeFileSync(dataPath, JSON.stringify({ title: 'From file', status: 'backlog' }), 'utf8') 483 484 const calls = [] 485 const fetchImpl = async (url, init) => { 486 calls.push({ url: String(url), init }) 487 return jsonResponse({ ok: true }) 488 } 489 490 const exitCode = await runCli( 491 ['tasks', 'create', '--data', `@${dataPath}`], 492 { 493 fetchImpl, 494 stdout, 495 stderr, 496 env: {}, 497 cwd: process.cwd(), 498 } 499 ) 500 501 assert.equal(exitCode, 0) 502 assert.equal(calls.length, 1) 503 assert.equal(calls[0].init.headers['Content-Type'], 'application/json') 504 assert.deepEqual(JSON.parse(calls[0].init.body), { title: 'From file', status: 'backlog' }) 505 506 fs.rmSync(tmpDir, { recursive: true, force: true }) 507 }) 508 509 test('runCli falls back to platform-api-key.txt when no env key is provided', async () => { 510 const stdout = makeWritable() 511 const stderr = makeWritable() 512 const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-cli-key-')) 513 fs.writeFileSync(path.join(tmpDir, 'platform-api-key.txt'), 'file-key\n', 'utf8') 514 515 const calls = [] 516 const fetchImpl = async (url, init) => { 517 calls.push({ url: String(url), init }) 518 return jsonResponse({ ok: true }) 519 } 520 521 const exitCode = await runCli( 522 ['runs', 'list'], 523 { 524 fetchImpl, 525 stdout, 526 stderr, 527 env: {}, 528 cwd: tmpDir, 529 } 530 ) 531 532 assert.equal(exitCode, 0) 533 assert.equal(calls.length, 1) 534 assert.equal(calls[0].init.headers['X-Access-Key'], 'file-key') 535 536 fs.rmSync(tmpDir, { recursive: true, force: true }) 537 }) 538 539 test('all command definitions execute with a mocked API transport', async () => { 540 const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-cli-all-')) 541 const uploadPath = path.join(tmpDir, 'upload.txt') 542 fs.writeFileSync(uploadPath, 'upload payload', 'utf8') 543 544 for (const command of COMMANDS) { 545 const stdout = makeWritable() 546 const stderr = makeWritable() 547 const pathArgs = extractPathParams(command.route).map((name, index) => { 548 if (name === 'filename') return `file-${index}.txt` 549 return `${name}-${index + 1}` 550 }) 551 552 const argv = [command.group, command.action, ...pathArgs] 553 if (command.requestType === 'upload') { 554 argv.push(uploadPath) 555 } 556 557 if (command.bodyFlagMap && Object.prototype.hasOwnProperty.call(command.bodyFlagMap, 'key')) { 558 argv.push('--key', 'test-key') 559 } 560 if (command.bodyFlagMap && Object.prototype.hasOwnProperty.call(command.bodyFlagMap, 'text')) { 561 argv.push('--text', 'hello from test') 562 } 563 564 const calls = [] 565 const fetchImpl = async (url, init) => { 566 calls.push({ url: String(url), init }) 567 568 if (command.clientGetRoute) { 569 const id = pathArgs[0] 570 return jsonResponse([{ id }]) 571 } 572 573 if (command.responseType === 'binary') { 574 return new Response(Buffer.from('ok'), { 575 status: 200, 576 headers: { 'content-type': 'application/octet-stream' }, 577 }) 578 } 579 580 if (command.responseType === 'sse') { 581 return new Response('data: {"t":"md","text":"ok"}\n\n', { 582 status: 200, 583 headers: { 'content-type': 'text/event-stream' }, 584 }) 585 } 586 587 return jsonResponse({ ok: true }) 588 } 589 590 const exitCode = await runCli(argv, { 591 fetchImpl, 592 stdout, 593 stderr, 594 env: { 595 SWARMCLAW_API_KEY: 'test-key', 596 }, 597 cwd: process.cwd(), 598 }) 599 600 assert.equal(exitCode, 0, `command failed: ${command.group} ${command.action}`) 601 assert.equal(calls.length, 1, `expected one request for ${command.group} ${command.action}`) 602 assert.equal(stderr.toString(), '', `unexpected stderr for ${command.group} ${command.action}`) 603 604 if (command.requestType === 'upload') { 605 assert.ok(Buffer.isBuffer(calls[0].init.body)) 606 assert.equal(calls[0].init.headers['x-filename'], 'upload.txt') 607 } 608 } 609 610 fs.rmSync(tmpDir, { recursive: true, force: true }) 611 })