Files
iot-dc3/dc3-cli/test/tool-command.test.ts
T
pnokerandClaude Code e1edbbcdc3 docs(cli): sync readme with new commands and fix documentation drift
adds the provider model and attachment command groups to the readme
with usage examples for every subcommand, documents device
import-template and alert point-profile, corrects the point history
option name from --count to --limit, fixes the mcp example key from
transport to type, adds config profile create/delete to the multi-
profile section, and aligns the test suite with the updated command
surface. also updates ci to run typecheck alongside tests.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-06 13:43:30 +08:00

316 lines
12 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Readable } from 'node:stream';
import { Command } from 'commander';
import { SilentExit } from '../src/utils/format.js';
import { handleFatalError } from '../src/core/errors.js';
vi.mock('../src/core/config-manager.js', () => ({
configManager: {
getActiveProfile: vi.fn(async () => ({ gateway: 'http://gw.test/' })),
getActiveProfileName: vi.fn(async () => 'default'),
getSettings: vi.fn(async () => ({})),
},
}));
vi.mock('../src/core/token-manager.js', () => ({
tokenManager: {
getState: vi.fn(),
needsRenewal: vi.fn(async () => false),
buildHeaders: (state: Record<string, unknown>) => ({
'Content-Type': 'application/json',
Authorization: `Bearer ${state.token}`,
}),
},
}));
import { registerToolCommand } from '../src/commands/tool.js';
import { tokenManager } from '../src/core/token-manager.js';
const oauthState = { token: 'rs256.jwt.value', authType: 'oauth' };
/** Result of one program invocation routed through the entry failure path. */
interface RunResult {
stdout: string;
stderr: string;
exitCode: number;
}
/**
* Parse argv like the bin entry does: exitOverride surfaces commander
* failures as rejections, and escaped failures flow through the same
* chokepoint runCli uses.
* @param args - arguments after `dc3`
* @returns captured streams and effective exit code
*/
async function run(args: string[]): Promise<RunResult> {
const program = new Command();
program.exitOverride();
registerToolCommand(program);
let stdout = '';
let stderr = '';
process.exitCode = 0;
const outSpy = vi
.spyOn(process.stdout, 'write')
.mockImplementation((chunk) => ((stdout += String(chunk)), true));
const errSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation((chunk) => ((stderr += String(chunk)), true));
try {
try {
await program.parseAsync(args, { from: 'user' });
} catch (error) {
if (!(error instanceof SilentExit)) {
try {
handleFatalError(error as Error);
} catch {
// handleFatalError reported and re-threw by contract
}
}
}
} finally {
outSpy.mockRestore();
errSpy.mockRestore();
}
return { stdout, stderr, exitCode: process.exitCode ?? 0 };
}
/**
* Decode the single machine envelope a failure path may write to stdout.
* @param stdout - the captured stdout of a failed invocation
* @returns the parsed envelope, after asserting it is a single document
*/
function parseEnvelope(stdout: string): {
ok: boolean;
error: { kind: string; code: string; message: string };
} {
const lines = stdout.split('\n').filter((line) => line.trim() !== '');
expect(lines, `exactly one envelope document, got: ${stdout}`).toHaveLength(1);
return JSON.parse(lines[0]) as { ok: boolean; error: { kind: string; code: string; message: string } };
}
/**
* Fetch mock answering a JSON-RPC result while recording the request body.
* @param result - the JSON-RPC result field of the response
* @returns a fetch stub for the /mcp endpoint
*/
function mcpFetch(result: unknown) {
return vi.fn(async (_url: string, init?: RequestInit) => {
const request = JSON.parse(String(init?.body)) as Record<string, unknown>;
return new Response(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
});
}
/**
* Decode the JSON-RPC params of the single captured /mcp request.
* @param fetchMock - the fetch stub whose single call is inspected
* @returns the parsed JSON-RPC params object
*/
function mcpRequestBody(fetchMock: ReturnType<typeof mcpFetch>): Record<string, unknown> {
expect(fetchMock).toHaveBeenCalledTimes(1);
return JSON.parse(String(fetchMock.mock.calls[0][1]?.body)) as Record<string, unknown>;
}
let directory: string;
beforeEach(() => {
vi.mocked(tokenManager.getState).mockResolvedValue(oauthState);
vi.stubGlobal('fetch', mcpFetch({ tools: [] }));
process.exitCode = 0;
directory = '';
});
afterEach(async () => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
process.exitCode = 0;
if (directory) {
await rm(directory, { recursive: true, force: true });
}
});
describe('tools list (F009)', () => {
it('prints exactly one JSON document and exits 0 on success', async () => {
vi.stubGlobal(
'fetch',
mcpFetch({ tools: [{ name: 'read_device', title: 'Read device' }] }),
);
const result = await run(['tools', 'list', '--format', 'json']);
expect(result.exitCode).toBe(0);
expect(result.stderr).toBe('');
// Single-document contract: concatenated documents would fail this parse.
expect(JSON.parse(result.stdout)).toEqual([
{ name: 'read_device', title: 'Read device', category: '' },
]);
});
it('an auth failure keeps exit 3 with one envelope and one stderr line', async () => {
vi.mocked(tokenManager.getState).mockResolvedValue(null);
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const result = await run(['tools', 'list', '--format', 'json']);
expect(result.exitCode).toBe(3);
expect(fetchMock).not.toHaveBeenCalled();
expect(result.stderr.startsWith('Error: ')).toBe(true);
expect(result.stderr.split('\n')).toHaveLength(2);
const envelope = parseEnvelope(result.stdout);
expect(envelope.ok).toBe(false);
expect(envelope.error.kind).toBe('auth');
});
it('a network failure keeps exit 2 with one envelope', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new TypeError('fetch failed');
}),
);
const result = await run(['tools', 'list', '--format', 'json']);
expect(result.exitCode).toBe(2);
const envelope = parseEnvelope(result.stdout);
expect(envelope.error.kind).toBe('network');
expect(result.stderr.startsWith('Error: ')).toBe(true);
});
});
describe('tools call --args object validation (F034, F009)', () => {
it('round-trips a JSON object and prints exactly one document with exit 0', async () => {
const fetchMock = mcpFetch({ content: [{ text: 'ok' }] });
vi.stubGlobal('fetch', fetchMock);
const result = await run(['tools', 'call', 'device.list', '--args', '{"limit":1}', '--format', 'json']);
expect(result.exitCode).toBe(0);
expect(result.stderr).toBe('');
expect(JSON.parse(result.stdout)).toEqual({ content: [{ text: 'ok' }] });
expect(mcpRequestBody(fetchMock).params).toEqual({
name: 'device.list',
arguments: { limit: 1 },
});
});
it.each([['--args', '[1,2,3]'], ['--args', 'null'], ['--args', '42'], ['--args', '"abc"']])(
'%s %s is rejected client-side with a structured validation error',
async (_flag, value) => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const result = await run(['tools', 'call', 'device.list', '--args', value, '--format', 'json']);
expect(result.exitCode).toBe(1);
expect(fetchMock).not.toHaveBeenCalled();
const envelope = parseEnvelope(result.stdout);
expect(envelope.error.kind).toBe('validation');
expect(envelope.error.message).toBe('--args must be a JSON object');
expect(result.stderr).toContain('--args must be a JSON object');
},
);
it('inline --args that is not JSON fails with exactly one document and exit 1', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const result = await run(['tools', 'call', 'device.list', '--args', 'not-json', '--format', 'json']);
expect(result.exitCode).toBe(1);
expect(fetchMock).not.toHaveBeenCalled();
const envelope = parseEnvelope(result.stdout);
expect(envelope.error.kind).toBe('validation');
expect(envelope.error.message).toBe('--args is not valid JSON');
expect(result.stderr).toBe('Error: --args is not valid JSON\n');
});
});
describe('tools call --args-file (F032)', () => {
it('sends a >200KB argument object from a file verbatim on the wire', async () => {
const blob = 'x'.repeat(220_000);
directory = await mkdtemp(join(tmpdir(), 'dc3-cli-tool-'));
const argsFile = join(directory, 'args.json');
await writeFile(argsFile, JSON.stringify({ blob }), 'utf8');
const fetchMock = mcpFetch({ ok: true });
vi.stubGlobal('fetch', fetchMock);
const result = await run(['tools', 'call', 'bulk.load', '--args-file', argsFile, '--format', 'json']);
expect(result.exitCode).toBe(0);
const params = mcpRequestBody(fetchMock).params as { arguments: { blob: string } };
expect(params.arguments.blob).toHaveLength(220_000);
expect(params.name).toBe('bulk.load');
expect(JSON.parse(result.stdout)).toEqual({ ok: true });
});
it("reads the arguments from stdin via '-'", async () => {
const originalStdin = process.stdin;
Object.defineProperty(process, 'stdin', {
value: Readable.from([JSON.stringify({ from: 'stdin', n: 7 })]),
configurable: true,
});
try {
const fetchMock = mcpFetch({ ok: true });
vi.stubGlobal('fetch', fetchMock);
const result = await run(['tools', 'call', 't.tool', '--args-file', '-', '--format', 'json']);
expect(result.exitCode).toBe(0);
const params = mcpRequestBody(fetchMock).params as { arguments: unknown };
expect(params.arguments).toEqual({ from: 'stdin', n: 7 });
} finally {
Object.defineProperty(process, 'stdin', { value: originalStdin, configurable: true });
}
});
it('rejects a --args-file that is not valid JSON before any request', async () => {
directory = await mkdtemp(join(tmpdir(), 'dc3-cli-tool-'));
const argsFile = join(directory, 'bad.json');
await writeFile(argsFile, '{not-json', 'utf8');
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const result = await run(['tools', 'call', 't.tool', '--args-file', argsFile, '--format', 'json']);
expect(result.exitCode).toBe(1);
expect(fetchMock).not.toHaveBeenCalled();
const envelope = parseEnvelope(result.stdout);
expect(envelope.error.kind).toBe('validation');
expect(envelope.error.message).toBe('--args-file is not valid JSON');
});
it('rejects a missing --args-file with a structured error and zero requests', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const result = await run([
'tools', 'call', 't.tool', '--args-file', join(tmpdir(), 'dc3-no-such-args-file.json'), '--format', 'json',
]);
expect(result.exitCode).toBe(1);
expect(fetchMock).not.toHaveBeenCalled();
expect(parseEnvelope(result.stdout).error.message).toMatch(/Cannot read --args-file:/u);
});
it('rejects specifying both --args and --args-file as a usage error', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const result = await run(['tools', 'call', 't.tool', '--args', '{}', '--args-file', '-', '--format', 'json']);
expect(result.exitCode).toBe(1);
expect(fetchMock).not.toHaveBeenCalled();
expect(parseEnvelope(result.stdout).error.kind).toBe('usage');
expect(result.stderr).toContain('mutually exclusive');
});
it('rejects specifying neither --args nor --args-file as a usage error', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const result = await run(['tools', 'call', 't.tool', '--format', 'json']);
expect(result.exitCode).toBe(1);
expect(fetchMock).not.toHaveBeenCalled();
expect(parseEnvelope(result.stdout).error.kind).toBe('usage');
expect(result.stderr).toContain("--args <json>' (or '--args-file <path>')");
});
});