/ src / cli / index.test.js
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  })