Files
dify/cli/test/testcases/error-handling/exit-code.test.ts
gigglewang c38c5d375e test(cli): add integration test suite for Discovery, Run, Output, Error Handling and CLI Framework
Add comprehensive integration tests under cli/test/testcases/ covering:

Discovery:
- App list (list, single, all-workspaces)
- Describe App
- Cross-workspace query

Run:
- Basic App execution
- Streaming output
- HITL (Human-in-the-Loop) — all 19 cases incl. multi-action / expired-token / already-consumed
- File input
- Conversation mode
- Environment variable injection
- Cache and version consistency

Output:
- JSON/YAML output
- Table output

Error Handling:
- Exit code end-to-end validation
- Error message spec

CLI Framework:
- Global Flags
- Non-Interactive mode

Also extend test fixtures:
- scenarios.ts: add hitl-pause-multi-action / hitl-resume-expired-token / hitl-resume-already-consumed
- server.ts: add GET /form/human_input route, multi-action HITL response, expired/consumed token error handling

Known bugs tracked as it.todo:
- WTA-249: server 4xx in -o json mode exit code should be 1 (currently 0 in some cases)
- WTA-252: --help missing GLOBAL FLAGS section and Quick start examples
- WTA-255: hosts.yml YAML parse failure should output JSON envelope
- WTA-257: uncaught TypeError should output JSON envelope in -o json mode
2026-05-22 10:46:18 +08:00

323 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Dify CLI/Error Handling/Exit Code 集成测试
*
* 用例来源飞书文档《Dify CLI Enhanced》— Dify CLI/Error Handling/Exit Code29 条)
*
* 测试策略:
* - 通过 runGetApp / runDescribeApp / runApp + startMock() 端到端触发各种错误场景
* - 验证抛出的 BaseError.exit() 符合 ExitCode 规范
* - ExitCode 映射逻辑已在 src/errors/codes.test.ts 完整覆盖;此处验证集成路径的 exit code 流转
*
* ExitCode 规范(来自 src/errors/codes.ts
* Success = 0 Generic = 1 Usage = 2 Auth = 4 VersionCompat = 6
*/
import type { HostsBundle } from '../../../src/auth/hosts.js'
import type { DifyMock } from '../../fixtures/dify-mock/server.js'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { loadAppInfoCache } from '../../../src/cache/app-info.js'
import { runDescribeApp } from '../../../src/commands/describe/app/run.js'
import { runGetApp } from '../../../src/commands/get/app/run.js'
import { runApp } from '../../../src/commands/run/app/run.js'
import { BaseError, isBaseError } from '../../../src/errors/base.js'
import { ErrorCode, ExitCode } from '../../../src/errors/codes.js'
import { createClient } from '../../../src/http/client.js'
import { bufferStreams } from '../../../src/io/streams.js'
import { hostsBundleFixture } from '../../fixtures/dify-mock/scenarios.js'
import { startMock } from '../../fixtures/dify-mock/server.js'
const baseBundle = hostsBundleFixture({ includeAllWorkspaces: true })
/** 执行 fn捕获 BaseError 后返回 exit code非 BaseError 则 rethrow */
async function captureExit(fn: () => Promise<unknown>): Promise<number> {
try {
await fn()
return ExitCode.Success
}
catch (e) {
if (e instanceof BaseError)
return e.exit()
throw e
}
}
describe('Dify CLI/Error Handling/Exit Code', () => {
let mock: DifyMock
let dir: string
beforeAll(async () => {
mock = await startMock({ scenario: 'happy' })
})
beforeEach(async () => {
mock.setScenario('happy')
mock.reset()
dir = await mkdtemp(join(tmpdir(), 'difyctl-exit-'))
})
afterEach(async () => {
await rm(dir, { recursive: true, force: true })
})
afterAll(async () => {
await mock.stop()
})
function http() {
return createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 })
}
async function cache() {
return loadAppInfoCache({ configDir: dir,
})
}
// ── ExitCode.Success = 0 ──────────────────────────────────────────────────
it('成功命令 exit code 为 0get app 正常返回)[P0]', async () => {
const code = await captureExit(() => runGetApp({}, { bundle: baseBundle, http: http() }))
expect(code).toBe(ExitCode.Success)
})
it('成功命令 exit code 为 0describe app 正常返回)[P0]', async () => {
const c = await cache()
const code = await captureExit(() =>
runDescribeApp({ appId: 'app-1' }, { bundle: baseBundle, http: http(), host: mock.url, cache: c }),
)
expect(code).toBe(ExitCode.Success)
})
it('成功命令 exit code 为 0run app chat 正常执行)[P0]', async () => {
const c = await cache()
const io = bufferStreams()
const code = await captureExit(() =>
runApp({ appId: 'app-1', message: 'hi' }, { bundle: baseBundle, http: http(), host: mock.url, io, cache: c }),
)
expect(code).toBe(ExitCode.Success)
})
// ── ExitCode.Auth = 4 ─────────────────────────────────────────────────────
it('authentication errorauth-expiredexit code 为 4 [P0]', async () => {
mock.setScenario('auth-expired')
const code = await captureExit(() => runGetApp({}, { bundle: baseBundle, http: http() }))
expect(code).toBe(ExitCode.Auth)
})
it('authentication errorrun app auth-expiredexit code 为 4 [P0]', async () => {
mock.setScenario('auth-expired')
const io = bufferStreams()
const code = await captureExit(() =>
runApp({ appId: 'app-1', message: 'hi' }, { bundle: baseBundle, http: http(), host: mock.url, io }),
)
expect(code).toBe(ExitCode.Auth)
})
it('auth error exit code 区别于 generic error4 ≠ 1[P1]', async () => {
mock.setScenario('auth-expired')
const authCode = await captureExit(() => runGetApp({}, { bundle: baseBundle, http: http() }))
mock.setScenario('server-5xx')
const genericCode = await captureExit(() => runGetApp({}, { bundle: baseBundle, http: http() }))
expect(authCode).toBe(ExitCode.Auth)
expect(genericCode).toBe(ExitCode.Generic)
expect(authCode).not.toBe(genericCode)
})
// ── ExitCode.Generic = 1 ──────────────────────────────────────────────────
it('app not found exit code 为 1Generic[P0]', async () => {
const code = await captureExit(() =>
runGetApp({ appId: 'app-nonexistent' }, { bundle: baseBundle, http: http() }),
)
expect(code).toBe(ExitCode.Generic)
})
it('server 500 exit code 为 1Generic[P0]', async () => {
mock.setScenario('server-5xx')
const code = await captureExit(() => runGetApp({}, { bundle: baseBundle, http: http() }))
expect(code).toBe(ExitCode.Generic)
})
it('network errorrate-limited 429exit code 为 1Generic[P0]', async () => {
mock.setScenario('rate-limited')
const code = await captureExit(() => runGetApp({}, { bundle: baseBundle, http: http() }))
expect(code).toBe(ExitCode.Generic)
})
it('upload failedserver-5xx 场景exit code 为 1Generic[P1]', async () => {
mock.setScenario('server-5xx')
const code = await captureExit(() =>
runGetApp({ appId: 'app-nonexistent' }, { bundle: baseBundle, http: http() }),
)
expect(code).toBe(ExitCode.Generic)
})
// ── ExitCode.Usage = 2 ────────────────────────────────────────────────────
it('参数错误(--limit 越界exit code 为 2Usage[P0]', async () => {
const code = await captureExit(() =>
runGetApp({ limitRaw: '999' }, { bundle: baseBundle, http: http() }),
)
expect(code).toBe(ExitCode.Usage)
})
it('参数错误(--limit 非数字exit code 为 2Usage[P0]', async () => {
const code = await captureExit(() =>
runGetApp({ limitRaw: 'abc' }, { bundle: baseBundle, http: http() }),
)
expect(code).toBe(ExitCode.Usage)
})
it('参数错误(--inputs 非法 JSONexit code 为 2Usage[P0]', async () => {
const io = bufferStreams()
const code = await captureExit(() =>
runApp({ appId: 'app-2', inputsJson: 'notjson' }, { bundle: baseBundle, http: http(), host: mock.url, io }),
)
expect(code).toBe(ExitCode.Usage)
})
it('参数错误(--inputs 为数组而非对象exit code 为 2Usage[P0]', async () => {
const io = bufferStreams()
const code = await captureExit(() =>
runApp({ appId: 'app-2', inputsJson: '[1,2,3]' }, { bundle: baseBundle, http: http(), host: mock.url, io }),
)
expect(code).toBe(ExitCode.Usage)
})
it('workflow app 传入 positional message exit code 为 2Usage[P0]', async () => {
const c = await cache()
const io = bufferStreams()
const code = await captureExit(() =>
runApp({ appId: 'app-2', message: 'hi' }, { bundle: baseBundle, http: http(), host: mock.url, io, cache: c }),
)
expect(code).toBe(ExitCode.Usage)
})
it('no workspace 时 exit code 为 2UsageMissingArg[P0]', async () => {
const minimal: HostsBundle = { current_host: 'h', token_storage: 'file' }
const code = await captureExit(() => runGetApp({}, { bundle: minimal, http: http() }))
expect(code).toBe(ExitCode.Usage)
})
it('--file 参数格式错误 exit code 为 2Usage[P0]', async () => {
const c = await cache()
const io = bufferStreams()
const code = await captureExit(() =>
runApp({ appId: 'app-2', files: ['invalidflag'] }, { bundle: baseBundle, http: http(), host: mock.url, io, cache: c }),
)
expect(code).toBe(ExitCode.Usage)
})
// ── 不同错误类型 exit code 可区分 ──────────────────────────────────────────
it('不同错误类型 exit code 可区分Auth=4, Usage=2, Generic=1[P1]', async () => {
// Auth
mock.setScenario('auth-expired')
const authCode = await captureExit(() => runGetApp({}, { bundle: baseBundle, http: http() }))
// Usage
const usageCode = await captureExit(() =>
runGetApp({ limitRaw: '999' }, { bundle: baseBundle, http: http() }),
)
// Generic
mock.setScenario('server-5xx')
const genericCode = await captureExit(() => runGetApp({}, { bundle: baseBundle, http: http() }))
expect(authCode).toBe(ExitCode.Auth)
expect(usageCode).toBe(ExitCode.Usage)
expect(genericCode).toBe(ExitCode.Generic)
// 三种 exit code 互不相同
expect(new Set([authCode, usageCode, genericCode]).size).toBe(3)
})
it('多次执行同一失败场景 exit code 一致 [P1]', async () => {
const codes = await Promise.all(
[0, 1, 2].map(() => captureExit(() => runGetApp({ appId: 'app-nonexistent' }, { bundle: baseBundle, http: http() }))),
)
expect(new Set(codes).size).toBe(1)
expect(codes[0]).toBe(ExitCode.Generic)
})
// ── JSON/YAML 模式错误仍返回非 0 exit code ─────────────────────────────────
it('JSON 模式(-o json下 server-5xx 错误 exit code 仍为 1Generic[P0]', async () => {
mock.setScenario('server-5xx')
const code = await captureExit(() => runGetApp({ format: 'json' }, { bundle: baseBundle, http: http() }))
expect(code).toBe(ExitCode.Generic)
expect(code).not.toBe(ExitCode.Success)
})
it('JSON 模式(-o json下 auth 错误 exit code 为 4 [P0]', async () => {
mock.setScenario('auth-expired')
const code = await captureExit(() => runGetApp({ format: 'json' }, { bundle: baseBundle, http: http() }))
expect(code).toBe(ExitCode.Auth)
})
it('YAML 模式(-o yaml下错误 exit code 非 0 [P1]', async () => {
mock.setScenario('server-5xx')
const code = await captureExit(() => runGetApp({ format: 'yaml' }, { bundle: baseBundle, http: http() }))
expect(code).not.toBe(ExitCode.Success)
})
it('server 4xxapp not found在 -o json 模式 exit code 为 1 [P0]', async () => {
const code = await captureExit(() =>
runGetApp({ appId: 'app-nonexistent', format: 'json' }, { bundle: baseBundle, http: http() }),
)
expect(code).toBe(ExitCode.Generic)
expect(code).not.toBe(ExitCode.Success)
})
// ── stderr 与 stdout 分离 ─────────────────────────────────────────────────
it('stderr 输出错误时 stdout 保持干净get app 失败后 outBuf 为空)[P0]', async () => {
mock.setScenario('server-5xx')
const io = bufferStreams()
try {
await runApp({ appId: 'app-1', message: 'hi' }, { bundle: baseBundle, http: http(), host: mock.url, io })
}
catch {
// expected
}
// stdout 不应输出错误信息
expect(io.outBuf()).toBe('')
})
it('stdout 输出成功内容时 stderr 不含错误(仅 hint[P1]', async () => {
const c = await cache()
const io = bufferStreams()
await runApp({ appId: 'app-1', message: 'hi' }, { bundle: baseBundle, http: http(), host: mock.url, io, cache: c })
// stdout 有答案
expect(io.outBuf()).toContain('echo: hi')
// stderr 无 "error" 关键词(仅 conversation hint
expect(io.errBuf()).not.toContain('error:')
expect(io.errBuf()).not.toContain('Error:')
})
// ── ExitCode 枚举值稳定性 ────────────────────────────────────────────────
it('ExitCode 枚举值稳定Success=0 Generic=1 Usage=2 Auth=4 VersionCompat=6[P0]', () => {
expect(ExitCode.Success).toBe(0)
expect(ExitCode.Generic).toBe(1)
expect(ExitCode.Usage).toBe(2)
expect(ExitCode.Auth).toBe(4)
expect(ExitCode.VersionCompat).toBe(6)
})
it('isBaseError 正确识别 BaseError 实例 [P0]', () => {
const err = new BaseError({ code: ErrorCode.Unknown, message: 'test' })
expect(isBaseError(err)).toBe(true)
expect(isBaseError(new Error('plain'))).toBe(false)
expect(isBaseError(null)).toBe(false)
expect(isBaseError(undefined)).toBe(false)
})
it('validation error--inputs 与 --inputs-file 互斥exit code 为 2 [P0]', async () => {
const { writeFile } = await import('node:fs/promises')
const f = join(dir, 'f.json')
await writeFile(f, '{}')
const io = bufferStreams()
const code = await captureExit(() =>
runApp({ appId: 'app-2', inputsJson: '{}', inputsFile: f }, { bundle: baseBundle, http: http(), host: mock.url, io }),
)
expect(code).toBe(ExitCode.Usage)
})
})