dify/cli/src/framework/run.test.ts
L1nSn0w f0fd7ddb60
feat(cli): unified help system (#36896)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 07:27:28 +00:00

509 lines
18 KiB
TypeScript

import type { CommandConstructor } from './command'
import type { CommandTree } from './registry'
import { describe, expect, it } from 'vitest'
import { BaseError, HttpClientError, newError } from '@/errors/base'
import { ErrorCode, ExitCode } from '@/errors/codes'
import { CONTRACT } from '@/help/contract'
import { Command } from './command'
import { run, sniffOutputFormat } from './run'
describe('sniffOutputFormat', () => {
it('returns empty for empty argv', () => {
expect(sniffOutputFormat([])).toBe('')
})
it('returns empty when no output flag present', () => {
expect(sniffOutputFormat(['cmd'])).toBe('')
expect(sniffOutputFormat(['cmd', 'pos', '--flag'])).toBe('')
})
it('parses --output=value', () => {
expect(sniffOutputFormat(['cmd', '--output=json'])).toBe('json')
})
it('parses --output value (space form)', () => {
expect(sniffOutputFormat(['cmd', '--output', 'json'])).toBe('json')
})
it('parses -o value (space form)', () => {
expect(sniffOutputFormat(['cmd', '-o', 'yaml'])).toBe('yaml')
})
it('parses -o=value', () => {
expect(sniffOutputFormat(['cmd', '-o=text'])).toBe('text')
})
it('returns empty when next token after space-form is itself a flag', () => {
expect(sniffOutputFormat(['cmd', '-o', '--other'])).toBe('')
expect(sniffOutputFormat(['cmd', '--output', '--other'])).toBe('')
})
it('stops at end-of-flags marker --', () => {
expect(sniffOutputFormat(['cmd', '--', '-o', 'json'])).toBe('')
expect(sniffOutputFormat(['cmd', '--', '--output=json'])).toBe('')
})
it('first occurrence wins on duplicate flags', () => {
expect(sniffOutputFormat(['cmd', '--output=json', '--output=yaml'])).toBe('json')
})
it('does NOT support concatenated short form -o<val>', () => {
expect(sniffOutputFormat(['cmd', '-ojson'])).toBe('')
})
})
type Captured = {
stdout: string
stderr: string
exit: number | undefined
}
async function captureRun(tree: CommandTree, argv: string[]): Promise<Captured> {
const captured: Captured = { stdout: '', stderr: '', exit: undefined }
const origStdout = process.stdout.write.bind(process.stdout)
const origStderr = process.stderr.write.bind(process.stderr)
const origExit = process.exit.bind(process)
process.stdout.write = ((chunk: string | Uint8Array) => {
captured.stdout += typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)
return true
}) as typeof process.stdout.write
process.stderr.write = ((chunk: string | Uint8Array) => {
captured.stderr += typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)
return true
}) as typeof process.stderr.write
process.exit = ((code?: number) => {
captured.exit = code
// do not throw; the catch block should `return` after exit
}) as typeof process.exit
try {
await run(tree, argv)
}
finally {
process.stdout.write = origStdout
process.stderr.write = origStderr
process.exit = origExit
}
return captured
}
function makeTree(cmd: CommandConstructor): CommandTree {
return { cmd: { command: cmd, subcommands: {} } }
}
describe('run() catch routing', () => {
it('routes BaseError to human format with semantic exit code', async () => {
class Throwing extends Command {
async run(_argv: string[]) {
throw new BaseError({
code: ErrorCode.NotLoggedIn,
message: 'not logged in',
hint: 'run `difyctl auth login` to authenticate',
})
}
}
const result = await captureRun(makeTree(Throwing), ['cmd'])
expect(result.stderr).toBe(
'not_logged_in: not logged in\nhint: run `difyctl auth login` to authenticate\n',
)
expect(result.exit).toBe(ExitCode.Auth)
expect(result.stdout).toBe('')
})
it('routes BaseError to JSON envelope with --output=json', async () => {
class Throwing extends Command {
async run(_argv: string[]) {
throw new BaseError({
code: ErrorCode.NotLoggedIn,
message: 'not logged in',
hint: 'run `difyctl auth login` to authenticate',
})
}
}
const result = await captureRun(makeTree(Throwing), ['cmd', '--output=json'])
expect(result.stderr).toBe(
`${JSON.stringify({
error: {
code: 'not_logged_in',
message: 'not logged in',
hint: 'run `difyctl auth login` to authenticate',
},
})}\n`,
)
expect(result.exit).toBe(ExitCode.Auth)
})
it('routes BaseError to JSON envelope with -o json (space form)', async () => {
class Throwing extends Command {
async run(_argv: string[]) {
throw newError(ErrorCode.NotLoggedIn, 'not logged in')
}
}
const result = await captureRun(makeTree(Throwing), ['cmd', '-o', 'json'])
expect(result.stderr).toContain('"code":"not_logged_in"')
expect(result.stderr.startsWith('{')).toBe(true)
expect(result.exit).toBe(ExitCode.Auth)
})
it('keeps human format when -- separates positional from --output', async () => {
class Throwing extends Command {
async run(_argv: string[]) {
throw newError(ErrorCode.NotLoggedIn, 'not logged in')
}
}
const result = await captureRun(makeTree(Throwing), ['cmd', '--', '--output=json'])
expect(result.stderr.startsWith('not_logged_in:')).toBe(true)
})
it('routes Usage error to exit code 2 with code prefix', async () => {
class Throwing extends Command {
async run(_argv: string[]) {
throw newError(ErrorCode.UsageInvalidFlag, 'bad flag')
}
}
const result = await captureRun(makeTree(Throwing), ['cmd'])
expect(result.stderr).toBe('usage_invalid_flag: bad flag\n')
expect(result.exit).toBe(ExitCode.Usage)
})
it('routes Server5xx error with http_status line and generic exit', async () => {
class Throwing extends Command {
async run(_argv: string[]) {
throw HttpClientError.from(newError(ErrorCode.Server5xx, 'upstream boom')).withHttpStatus(502)
}
}
const result = await captureRun(makeTree(Throwing), ['cmd'])
expect(result.stderr).toBe('server_5xx: upstream boom\nhttp_status: 502\n')
expect(result.exit).toBe(ExitCode.Generic)
})
it('renders request line and http_status when both are present', async () => {
class Throwing extends Command {
async run(_argv: string[]) {
throw HttpClientError.from(newError(ErrorCode.Server5xx, 'upstream boom'))
.withRequest('GET', 'https://api.dify.ai/v1/me')
.withHttpStatus(502)
}
}
const result = await captureRun(makeTree(Throwing), ['cmd'])
expect(result.stderr).toBe(
'server_5xx: upstream boom\nrequest: GET https://api.dify.ai/v1/me\nhttp_status: 502\n',
)
expect(result.exit).toBe(ExitCode.Generic)
})
it('serializes method and url in JSON envelope', async () => {
class Throwing extends Command {
async run(_argv: string[]) {
throw HttpClientError.from(newError(ErrorCode.Server4xxOther, 'not found'))
.withRequest('GET', 'https://api.dify.ai/v1/apps/x')
.withHttpStatus(404)
}
}
const result = await captureRun(makeTree(Throwing), ['cmd', '--output=json'])
const envelope = JSON.parse(result.stderr.trim())
expect(envelope.error.method).toBe('GET')
expect(envelope.error.url).toBe('https://api.dify.ai/v1/apps/x')
expect(envelope.error.http_status).toBe(404)
expect(envelope.error.code).toBe('server_4xx_other')
expect(result.exit).toBe(ExitCode.Generic)
})
it('falls through to generic Error branch and exits 1', async () => {
class Throwing extends Command {
async run(_argv: string[]) {
throw new Error('oops')
}
}
const result = await captureRun(makeTree(Throwing), ['cmd'])
expect(result.stderr).toBe('oops\n')
expect(result.exit).toBe(1)
})
it('handles non-Error throw via String() coercion', async () => {
class Throwing extends Command {
async run(_argv: string[]) {
// eslint-disable-next-line no-throw-literal
throw 'plain string'
}
}
const result = await captureRun(makeTree(Throwing), ['cmd'])
expect(result.stderr).toBe('plain string\n')
expect(result.exit).toBe(1)
})
it('does not call process.exit when command runs successfully', async () => {
class Ok extends Command {
async run(_argv: string[]) {
// returning void → run() does not write to stdout
}
}
const result = await captureRun({ cmd: { command: Ok, subcommands: {} } }, ['cmd'])
expect(result.stderr).toBe('')
expect(result.exit).toBeUndefined()
})
it('routes BaseError thrown from constructor through catch with JSON envelope', async () => {
class CtorBang extends Command {
constructor() {
super()
throw newError(ErrorCode.Unknown, 'ctor-bang')
}
async run(_argv: string[]) {}
}
const result = await captureRun(
{ cmd: { command: CtorBang, subcommands: {} } },
['cmd', '--output=json'],
)
expect(result.stderr).toContain('"code":"unknown"')
expect(result.stderr).toContain('"message":"ctor-bang"')
expect(result.exit).toBe(ExitCode.Generic)
})
})
describe('hidden commands', () => {
it('omits a hidden top-level command from printTopLevelHelp', async () => {
class Visible extends Command {
static override description = 'visible cmd'
async run() {}
}
class Hidden extends Command {
static override description = 'hidden cmd'
static hidden = true
async run() {}
}
const tree: CommandTree = {
'visible': { command: Visible, subcommands: {} },
'secret-debug': { command: Hidden, subcommands: {} },
}
const result = await captureRun(tree, [])
expect(result.stdout).toContain('visible')
expect(result.stdout).not.toContain('secret-debug')
})
it('omits a hidden subcommand from its topic listing', async () => {
class Public extends Command {
static override description = 'visible sub'
async run() {}
}
class HiddenSub extends Command {
static override description = 'hidden sub'
static hidden = true
async run() {}
}
const tree: CommandTree = {
topic: {
subcommands: {
'public': { command: Public, subcommands: {} },
'debug-only': { command: HiddenSub, subcommands: {} },
},
},
}
const result = await captureRun(tree, [])
expect(result.stdout).toContain('public')
expect(result.stdout).not.toContain('debug-only')
})
it('still resolves and executes a hidden command when invoked directly', async () => {
let ran = false
class Hidden extends Command {
static hidden = true
async run() { ran = true }
}
const tree: CommandTree = {
'secret-debug': { command: Hidden, subcommands: {} },
}
await captureRun(tree, ['secret-debug'])
expect(ran).toBe(true)
})
})
describe('deprecated commands', () => {
it('prints a deprecation warning to stderr before running', async () => {
class Old extends Command {
static deprecated = 'use `difyctl run app` instead; removal in 2.0'
async run() {
process.stdout.write('old-ran\n')
}
}
const tree: CommandTree = {
old: { command: Old, subcommands: {} },
}
const result = await captureRun(tree, ['old'])
expect(result.stderr).toBe(
'deprecated: use `difyctl run app` instead; removal in 2.0\n',
)
expect(result.stdout).toBe('old-ran\n')
expect(result.exit).toBeUndefined()
})
it('does not print a warning when deprecated is unset', async () => {
class Fresh extends Command {
async run() {}
}
const tree: CommandTree = {
fresh: { command: Fresh, subcommands: {} },
}
const result = await captureRun(tree, ['fresh'])
expect(result.stderr).toBe('')
})
})
describe('run() help routing', () => {
class GetApp extends Command {
static override description = 'List or get apps'
async run() {}
}
const tree: CommandTree = {
get: { subcommands: { app: { command: GetApp, subcommands: {} } } },
}
it('renders a concept guide for `help <topic>`', async () => {
const result = await captureRun(tree, ['help', 'account'])
expect(result.stdout).toContain('account-bearer onboarding')
expect(result.stdout).toContain('difyctl auth login')
expect(result.stdout).not.toContain('COMMANDS')
expect(result.exit).toBeUndefined()
})
it('renders the environment guide for `help environment`', async () => {
const result = await captureRun(tree, ['help', 'environment'])
expect(result.stdout).toContain('ENVIRONMENT VARIABLES')
})
it('renders per-command help for `help <cmd...>`', async () => {
const result = await captureRun(tree, ['help', 'get', 'app'])
expect(result.stdout).toContain('List or get apps')
expect(result.stdout).toContain('USAGE')
expect(result.exit).toBeUndefined()
})
it('suggests and exits non-zero for an unknown help topic', async () => {
const result = await captureRun(tree, ['help', 'xyz'])
expect(result.stderr).toContain('unknown help topic: xyz')
expect(result.exit).toBe(1)
})
it('lists USAGE, COMMANDS, EXAMPLES, GLOBAL FLAGS, GUIDES and LEARN MORE in the top-level overview', async () => {
const result = await captureRun(tree, ['help'])
expect(result.stdout).toContain('USAGE')
expect(result.stdout).toContain('COMMANDS')
expect(result.stdout).toContain('EXAMPLES')
expect(result.stdout).toContain('GLOBAL FLAGS')
expect(result.stdout).toContain('GUIDES')
expect(result.stdout).toContain('LEARN MORE')
expect(result.stdout).toContain('account')
expect(result.stdout).toContain('environment')
expect(result.stdout).toContain('external')
})
it('derives the GLOBAL FLAGS output format list from the contract', async () => {
const result = await captureRun(tree, ['help'])
expect(result.stdout).toContain('-o, --output')
expect(result.stdout).toContain(`Output format: ${CONTRACT.outputFormats.join('|')}`)
expect(result.stdout).toContain('--http-retry')
})
it('does not print trailing whitespace on group rows', async () => {
const groupTree: CommandTree = {
auth: { subcommands: { devices: { subcommands: { list: { command: GetApp, subcommands: {} } } } } },
}
const result = await captureRun(groupTree, ['help'])
expect(result.stdout).not.toMatch(/ \n/)
})
it('emits a JSON site map for `help -o json`', async () => {
const result = await captureRun(tree, ['help', '-o', 'json'])
const obj = JSON.parse(result.stdout)
expect(obj.bin).toBe('difyctl')
expect(obj.contract.exitCodes).toBeDefined()
expect(obj.commands.some((c: { command: string }) => c.command === 'get app')).toBe(true)
expect(obj.topics.some((t: { name: string }) => t.name === 'account')).toBe(true)
expect(result.exit).toBeUndefined()
})
it('emits a JSON descriptor for `help <cmd> -o json`', async () => {
const result = await captureRun(tree, ['help', 'get', 'app', '-o', 'json'])
const obj = JSON.parse(result.stdout)
expect(obj.command).toBe('get app')
expect(Array.isArray(obj.flags)).toBe(true)
})
class Login extends Command {
static override description = 'Sign in'
async run() {}
}
class DevicesList extends Command {
static override description = 'List sessions'
async run() {}
}
class DevicesRevoke extends Command {
static override description = 'Revoke a session'
async run() {}
}
const groupTree: CommandTree = {
auth: {
subcommands: {
login: { command: Login, subcommands: {} },
devices: {
subcommands: {
list: { command: DevicesList, subcommands: {} },
revoke: { command: DevicesRevoke, subcommands: {} },
},
},
},
},
}
it('drills into a namespace node for `<group> --help` instead of erroring', async () => {
const result = await captureRun(groupTree, ['auth', '--help'])
expect(result.stdout).toContain('auth login')
expect(result.stdout).toContain('auth devices list')
expect(result.stdout).toContain('auth devices revoke')
expect(result.stderr).not.toContain('unknown help topic')
expect(result.exit).toBeUndefined()
})
it('drills into a nested namespace node for `<group> <sub> --help`', async () => {
const result = await captureRun(groupTree, ['auth', 'devices', '--help'])
expect(result.stdout).toContain('auth devices list')
expect(result.stdout).toContain('auth devices revoke')
expect(result.stdout).not.toContain('auth login')
expect(result.stderr).not.toContain('unknown help topic')
expect(result.exit).toBeUndefined()
})
it('still errors for an unknown namespace-looking path', async () => {
const result = await captureRun(groupTree, ['auth', 'nope', '--help'])
expect(result.stderr).toContain('unknown help topic: auth nope')
expect(result.exit).toBe(1)
})
it('serializes the subtree as JSON for `<group> --help -o json`', async () => {
const result = await captureRun(groupTree, ['auth', '--help', '-o', 'json'])
expect(result.exit).toBeUndefined()
expect(result.stdout).not.toContain('COMMANDS')
const parsed = JSON.parse(result.stdout) as { commands: Array<{ command: string }> }
expect(parsed.commands.map(c => c.command)).toEqual([
'auth login',
'auth devices list',
'auth devices revoke',
])
})
it('renders full-depth leaves at the top level (third-level commands visible)', async () => {
const result = await captureRun(groupTree, [])
expect(result.stdout).toContain('auth login')
expect(result.stdout).toContain('auth devices list')
expect(result.stdout).toContain('auth devices revoke')
})
})