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
This commit is contained in:
gigglewang
2026-05-22 10:46:18 +08:00
parent 5381452de9
commit c38c5d375e
24 changed files with 5190 additions and 51 deletions

View File

@ -6,6 +6,7 @@ import { AppMetaClient } from '../../../api/app-meta.js'
import { AppRunClient } from '../../../api/app-run.js'
import { AppsClient } from '../../../api/apps.js'
import { FileUploadClient } from '../../../api/file-upload.js'
import { getEnv } from '../../../env/registry.js'
import { BaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'
import { FieldInfo } from '../../../types/app-meta.js'
@ -78,7 +79,7 @@ async function resolveInputs(
}
export async function runApp(opts: RunAppOptions, deps: RunAppDeps): Promise<void> {
const env = deps.envLookup ?? ((k: string) => process.env[k])
const env = deps.envLookup ?? getEnv
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
const apps = new AppsClient(deps.http)
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })

View File

@ -43,13 +43,29 @@ export function formatHelp(ctor: CommandConstructor, path: string): string {
}
if (ctor.flags && Object.keys(ctor.flags).length > 0) {
lines.push('FLAGS')
const globalFlags: Array<[string, FlagDefinition]> = []
const localFlags: Array<[string, FlagDefinition]> = []
for (const [name, def] of Object.entries(ctor.flags)) {
lines.push(` ${flagLabel(name, def)} ${def.description}${flagDefault(def)}`)
if (def.helpGroup === 'GLOBAL')
globalFlags.push([name, def])
else
localFlags.push([name, def])
}
lines.push('')
if (localFlags.length > 0) {
lines.push('FLAGS')
for (const [name, def] of localFlags)
lines.push(` ${flagLabel(name, def)} ${def.description}${flagDefault(def)}`)
lines.push('')
}
if (globalFlags.length > 0) {
lines.push('GLOBAL FLAGS')
for (const [name, def] of globalFlags)
lines.push(` ${flagLabel(name, def)} ${def.description}${flagDefault(def)}`)
lines.push('')
}
}
if (ctor.examples && ctor.examples.length > 0) {

View File

@ -79,14 +79,8 @@ function stringifyFormattedOutput(output: FormattedOutput<FormattedPrintable>):
case '':
case 'text':
return output.data.text()
case 'json':
return `${JSON.stringify(output.data.json(), null, 2)}\n`
case 'yaml':
return yaml.dump(output.data.json(), { indent: 2, lineWidth: -1 })
case 'name':
return `${toName(output.data)}\n`
default:
throw new Error(`output format ${JSON.stringify(output.format)} not supported, allowed: json, name, text, yaml`)
return stringifyJsonLike(output.format, output.data, 'json, name, text, yaml')
}
}
@ -95,14 +89,25 @@ function stringifyTableOutput(output: TableOutput<TablePrintable>): string {
case '':
case 'wide':
return renderTable(output)
case 'json':
return `${JSON.stringify(output.data.json(), null, 2)}\n`
case 'yaml':
return yaml.dump(output.data.json(), { indent: 2, lineWidth: -1 })
case 'name':
return `${toName(output.data)}\n`
default:
throw new Error(`output format ${JSON.stringify(output.format)} not supported, allowed: json, name, wide, yaml`)
return stringifyJsonLike(output.format, output.data, 'json, name, wide, yaml')
}
}
function stringifyJsonLike(
format: string,
data: TablePrintable | FormattedPrintable,
allowed: string,
): string {
switch (format) {
case 'json':
return `${JSON.stringify(data.json(), null, 2)}\n`
case 'yaml':
return yaml.dump(data.json(), { indent: 2, lineWidth: -1 })
case 'name':
return `${toName(data)}\n`
default:
throw new Error(`output format ${JSON.stringify(format)} not supported, allowed: ${allowed}`)
}
}
@ -158,15 +163,18 @@ function formatTable(rows: readonly (readonly string[])[]): string {
return ''
const colCount = rows[0]?.length ?? 0
const widths: number[] = Array.from({ length: colCount }, () => 0)
const rowWidths: number[][] = []
for (const row of rows) {
const rw: number[] = Array.from({ length: colCount }, (_v, i) => displayWidth(row[i] ?? ''))
rowWidths.push(rw)
for (let i = 0; i < colCount; i++) {
const cell = row[i] ?? ''
const w = displayWidth(cell)
const w = rw[i] ?? 0
if (w > (widths[i] ?? 0))
widths[i] = w
}
}
const lines = rows.map((row) => {
const lines = rows.map((row, rowIdx) => {
const rw = rowWidths[rowIdx] ?? []
const cells: string[] = []
for (let i = 0; i < colCount; i++) {
const cell = row[i] ?? ''
@ -175,7 +183,7 @@ function formatTable(rows: readonly (readonly string[])[]): string {
cells.push(cell)
}
else {
const pad = (widths[i] ?? 0) - displayWidth(cell) + 2
const pad = (widths[i] ?? 0) - (rw[i] ?? 0) + 2
cells.push(cell + ' '.repeat(pad))
}
}

View File

@ -1,5 +1,5 @@
import type { CommandTree } from './registry.js'
import { BaseError } from '../errors/base.js'
import { BaseError, unknownError } from '../errors/base.js'
import { formatErrorForCli } from '../errors/format.js'
import { formatHelp } from './help.js'
import { stringifyOutput } from './output.js'
@ -40,31 +40,38 @@ export async function run(tree: CommandTree, argv: string[]): Promise<void> {
process.exit(1)
}
const Ctor = resolved.command
if (typeof Ctor.deprecated === 'string' && Ctor.deprecated.length > 0)
process.stderr.write(`deprecated: ${Ctor.deprecated}\n`)
let cmd
try {
const Ctor = resolved.command
if (typeof Ctor.deprecated === 'string' && Ctor.deprecated.length > 0)
process.stderr.write(`deprecated: ${Ctor.deprecated}\n`)
const cmd = new Ctor()
const output = await cmd.run(argv.slice(resolved.path.length))
if (output !== undefined)
process.stdout.write(stringifyOutput(output))
cmd = new Ctor()
}
catch (err) {
handleRunError(err, argv)
return
}
let output
try {
output = await cmd.run(argv.slice(resolved.path.length))
}
catch (err) {
handleRunError(err, argv)
return
}
if (output === undefined)
return
try {
process.stdout.write(stringifyOutput(output))
}
catch (err) {
if ((err as NodeJS.ErrnoException).code === 'EPIPE')
process.exit(0)
if (err instanceof BaseError) {
const format = sniffOutputFormat(argv)
process.stderr.write(`${formatErrorForCli(err, { format, isErrTTY: process.stderr.isTTY })}\n`)
process.exit(err.exit())
return
}
if (err instanceof Error) {
process.stderr.write(`${err.message}\n`)
process.exit(1)
return
}
process.stderr.write(`${String(err)}\n`)
process.exit(1)
throw err
}
}
@ -109,10 +116,52 @@ function printTopLevelHelp(tree: CommandTree): void {
for (const [verb, sub] of Object.entries(node.subcommands)) {
if (sub.command?.hidden === true)
continue
const desc = sub.command?.description ?? ''
const desc = sub.command?.description ?? (Object.keys(sub.subcommands).length > 0 ? `${Object.keys(sub.subcommands).length} subcommands` : '')
process.stdout.write(` ${verb} ${desc}\n`)
}
}
process.stdout.write('\nGLOBAL FLAGS\n')
process.stdout.write(' -o, --output <string> output format (varies by command)\n')
process.stdout.write(' -w, --workspace <string> workspace id (overrides DIFY_WORKSPACE_ID and stored default)\n')
process.stdout.write(' --http-retry <integer> HTTP retry attempts for transient GET/PUT/DELETE errors. 0 disables. Overrides DIFYCTL_HTTP_RETRY.\n')
process.stdout.write('\nQUICK START\n')
process.stdout.write(' $ difyctl auth login\n')
process.stdout.write(' $ difyctl get app\n')
process.stdout.write(' $ difyctl run app <app-id> "hello"\n')
process.stdout.write('\n')
}
function safeWriteStderr(text: string): void {
try {
process.stderr.write(text)
}
catch (e) {
if ((e as NodeJS.ErrnoException).code !== 'EPIPE')
throw e
}
}
function handleRunError(err: unknown, argv: readonly string[]): void {
const format = sniffOutputFormat(argv)
if (err instanceof BaseError) {
safeWriteStderr(`${formatErrorForCli(err, { format, isErrTTY: process.stderr.isTTY })}\n`)
process.exit(err.exit())
return
}
if (err instanceof Error) {
const msg = format === 'json'
? formatErrorForCli(unknownError(err.message, err), { format, isErrTTY: process.stderr.isTTY })
: err.message
safeWriteStderr(`${msg}\n`)
process.exit(1)
return
}
const msg = format === 'json'
? formatErrorForCli(unknownError(String(err), err), { format, isErrTTY: process.stderr.isTTY })
: String(err)
safeWriteStderr(`${msg}\n`)
process.exit(1)
}

View File

@ -1,3 +1,5 @@
import type { HostsBundle } from '../../../src/auth/hosts.js'
export type Scenario
= | 'happy'
| 'sso'
@ -8,8 +10,12 @@ export type Scenario
| 'server-5xx'
| 'slow-down'
| 'stream-error'
| 'think-blocks'
| 'hitl-pause'
| 'hitl-pause-multi-action'
| 'hitl-resume'
| 'hitl-resume-expired-token'
| 'hitl-resume-already-consumed'
| 'server-version-empty'
| 'server-version-unsupported'
@ -70,6 +76,32 @@ export const WORKSPACES: WorkspaceFixture[] = [
{ id: 'ws-2', name: 'Other', role: 'normal', status: 'normal', is_current: false },
]
export type HostsBundleFixtureOptions = {
readonly bearer?: string
readonly currentHost?: string
readonly workspaceId?: string
readonly includeAllWorkspaces?: boolean
}
export function hostsBundleFixture(opts: HostsBundleFixtureOptions = {}): HostsBundle {
const bearer = opts.bearer ?? 'dfoa_test'
const currentHost = opts.currentHost ?? 'http://localhost'
const workspaceId = opts.workspaceId ?? 'ws-1'
const ws = WORKSPACES.find(w => w.id === workspaceId) ?? WORKSPACES.at(0)
if (ws === undefined)
throw new Error('WORKSPACES fixture is empty')
const available = opts.includeAllWorkspaces === true ? WORKSPACES : WORKSPACES.filter(w => w.id === ws.id)
return {
current_host: currentHost,
token_storage: 'file',
tokens: { bearer },
account: { id: ACCOUNT.id, email: ACCOUNT.email, name: ACCOUNT.name },
workspace: { id: ws.id, name: ws.name, role: ws.role },
available_workspaces: available.map(w => ({ id: w.id, name: w.name, role: w.role })),
}
}
export const APPS: AppFixture[] = [
{
id: 'app-1',

View File

@ -1,15 +1,19 @@
import type { DifyMock } from './server.js'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { startMock } from './server.js'
describe('dify-mock fixture server', () => {
let mock: DifyMock
beforeEach(async () => {
beforeAll(async () => {
mock = await startMock()
})
afterEach(async () => {
beforeEach(() => {
mock.setScenario('happy')
mock.reset()
})
afterAll(async () => {
await mock.stop()
})

View File

@ -14,6 +14,7 @@ export type DifyMock = {
port: number
scenario: Scenario
setScenario: (s: Scenario) => void
reset: () => void
stop: () => Promise<void>
/** Body of the most recent POST to /apps/:id/run */
lastRunBody: Record<string, unknown> | null
@ -34,7 +35,8 @@ function sseChunks(events: { event: string, data: Record<string, unknown> }[]):
return events.map(e => `data: ${JSON.stringify({ ...e.data, event: e.event })}\n\n`).join('')
}
function streamingRunResponse(mode: string, query: string, isAgent: boolean): string {
function streamingRunResponse(mode: string, query: string, isAgent: boolean, scenario: Scenario): string {
const thinkPrefix = scenario === 'think-blocks' ? '<think>reasoning</think>\n' : ''
if (mode === 'workflow') {
return sseChunks([
{ event: 'workflow_started', data: { id: 'wf-run-1', workflow_id: 'wf-1' } },
@ -45,14 +47,14 @@ function streamingRunResponse(mode: string, query: string, isAgent: boolean): st
}
if (mode === 'completion') {
return sseChunks([
{ event: 'message', data: { message_id: 'msg-1', mode, answer: 'echo: ' } },
{ event: 'message', data: { message_id: 'msg-1', mode, answer: `${thinkPrefix}echo: ` } },
{ event: 'message', data: { answer: query } },
{ event: 'message_end', data: { message_id: 'msg-1', task_id: 'task-1', metadata: {} } },
])
}
const evt = isAgent ? 'agent_message' : 'message'
const events: { event: string, data: Record<string, unknown> }[] = [
{ event: evt, data: { message_id: 'msg-1', conversation_id: 'conv-1', mode, answer: 'echo: ' } },
{ event: evt, data: { message_id: 'msg-1', conversation_id: 'conv-1', mode, answer: `${thinkPrefix}echo: ` } },
{ event: evt, data: { answer: query } },
]
if (isAgent)
@ -88,6 +90,33 @@ function hitlPauseResponse(): string {
])
}
function hitlPauseMultiActionResponse(): string {
return sseChunks([
{ event: 'workflow_started', data: { id: 'wf-run-hitl-1', workflow_id: 'wf-1' } },
{ event: 'node_started', data: { id: 'n1', title: 'First Node' } },
{
event: 'human_input_required',
data: {
task_id: 'task-hitl-1',
workflow_run_id: 'wf-run-hitl-1',
data: {
form_id: 'form-hitl-1',
node_id: 'n1',
node_title: 'First Node',
form_content: 'Please provide input',
inputs: [{ output_variable_name: 'name' }],
actions: [{ id: 'approve', title: 'Approve' }, { id: 'reject', title: 'Reject' }],
display_in_ui: true,
form_token: 'ft-hitl-multi',
resolved_default_values: {},
expiration_time: 9999999999,
},
},
},
{ event: 'workflow_paused', data: { reasons: [] } },
])
}
function hitlResumedResponse(): string {
return sseChunks([
{ event: 'node_started', data: { id: 'n2', title: 'After Resume' } },
@ -295,7 +324,10 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
if (scenario === 'hitl-pause') {
return new Response(hitlPauseResponse(), { status: 200, headers: { 'content-type': 'text/event-stream' } })
}
const sse = streamingRunResponse(app.mode, query, isAgent)
if (scenario === 'hitl-pause-multi-action') {
return new Response(hitlPauseMultiActionResponse(), { status: 200, headers: { 'content-type': 'text/event-stream' } })
}
const sse = streamingRunResponse(app.mode, query, isAgent, scenario)
return new Response(sse, { status: 200, headers: { 'content-type': 'text/event-stream' } })
})
@ -324,7 +356,20 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
return c.json({ result: 'success' })
})
app.get('/openapi/v1/apps/:id/form/human_input/:formToken', (c) => {
const scenario = getScenario()
if (scenario === 'hitl-pause-multi-action' || scenario === 'hitl-resume-already-consumed')
return c.json({ user_actions: [{ id: 'approve', title: 'Approve' }, { id: 'reject', title: 'Reject' }] })
// default: single action
return c.json({ user_actions: [{ id: 'submit', title: 'Submit' }] })
})
app.post('/openapi/v1/apps/:id/form/human_input/:formToken', (c) => {
const scenario = getScenario()
if (scenario === 'hitl-resume-expired-token')
return c.json({ error: { code: 'not_found', message: 'Form not found or token expired' } }, { status: 404 })
if (scenario === 'hitl-resume-already-consumed')
return c.json({ error: { code: 'token_consumed', message: 'Form token has already been consumed' } }, { status: 400 })
return c.json({})
})
@ -390,6 +435,10 @@ export function startMock(opts: DifyMockOptions = {}): Promise<DifyMock> {
port: addr.port,
scenario,
setScenario(s) { scenario = s },
reset() {
state.lastRunBody = null
state.uploadCallCount = 0
},
stop() {
return new Promise<void>((res, rej) => {
server.close(err => err ? rej(err) : res())

View File

@ -0,0 +1,354 @@
/**
* Dify CLI/CLI Framework/Global Flags 集成测试
*
* 用例来源飞书文档《Dify CLI Enhanced》— Dify CLI/CLI Framework/Global Flags33 条)
*
* 覆盖策略:
* - 通过 runGetApp / runApp / sniffOutputFormat 验证 -o/-w/--http-retry 等全局 flag 行为
* - formatHelp 渲染已在 src/framework/help.test.ts 覆盖,此处仅做集成断言
* - run() 错误路由已在 src/framework/run.test.ts 覆盖,此处验证真实命令路径
* - 标注 WTA-252 等已知缺陷help 结构优化)
*
* ExitCode 规范Success=0 Generic=1 Usage=2 Auth=4 VersionCompat=6
*/
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, vi } from 'vitest'
import { loadAppInfoCache } from '../../../src/cache/app-info.js'
import { HTTP_RETRY_DEFAULT, resolveRetryAttempts } from '../../../src/commands/_shared/global-flags.js'
import GetApp from '../../../src/commands/get/app/index.js'
import { runGetApp } from '../../../src/commands/get/app/run.js'
import RunApp from '../../../src/commands/run/app/index.js'
import { runApp } from '../../../src/commands/run/app/run.js'
import Version from '../../../src/commands/version/index.js'
import { BaseError } from '../../../src/errors/base.js'
import { formatErrorForCli } from '../../../src/errors/format.js'
import { formatHelp } from '../../../src/framework/help.js'
import { stringifyOutput, table } from '../../../src/framework/output.js'
import { sniffOutputFormat } from '../../../src/framework/run.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'
// eslint-disable-next-line no-control-regex
const ANSI_RE = /\x1B\[[0-9;]*[mGKHFA-DJsuhl]/
const hasAnsi = (s: string) => ANSI_RE.test(s)
const baseBundle = hostsBundleFixture({ includeAllWorkspaces: true })
describe('Dify CLI/CLI Framework/Global Flags', () => {
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-gflags-'))
})
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,
})
}
// ── -o / --output flag ────────────────────────────────────────────────────
it('-o json 可全局使用get app 输出合法 JSON[P0]', async () => {
const result = await runGetApp({ format: 'json' }, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: 'json', data: result.data }))
expect(() => JSON.parse(out)).not.toThrow()
const parsed = JSON.parse(out) as { data: unknown[] }
expect(Array.isArray(parsed.data)).toBe(true)
})
it('-o yaml 可全局使用get app 输出合法 YAML[P0]', async () => {
const { default: yaml } = await import('js-yaml')
const result = await runGetApp({ format: 'yaml' }, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: 'yaml', data: result.data }))
expect(() => yaml.load(out)).not.toThrow()
})
it('多个 global flags 可组合使用(-o json + workspace override[P0]', async () => {
const result = await runGetApp({ format: 'json', workspace: 'ws-2' }, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: 'json', data: result.data }))
const parsed = JSON.parse(out) as { data: Array<{ id: string }> }
const ids = parsed.data.map(r => r.id)
expect(ids).toContain('app-3')
expect(ids).not.toContain('app-1')
})
it('output flag 非法值抛出 "not supported" 错误 [P0]', async () => {
const result = await runGetApp({}, { bundle: baseBundle, http: http() })
expect(() =>
stringifyOutput(table({ format: 'bogus', data: result.data })),
).toThrow(/not supported/)
})
it('global flags 支持 shell pipe-o json 输出为 pipe 友好格式)[P1]', async () => {
const result = await runGetApp({ format: 'json' }, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: 'json', data: result.data }))
// pipe 友好:首字符为 {,末尾为 \n无 ANSI
expect(out.trim().startsWith('{')).toBe(true)
expect(out.endsWith('\n')).toBe(true)
expect(hasAnsi(out)).toBe(false)
})
it('global flags 不影响 stream 输出(--stream + -o json 同时工作)[P1]', async () => {
const c = await cache()
const io = bufferStreams()
await runApp(
{ appId: 'app-1', message: 'hi', stream: true, format: 'json' },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: c },
)
expect(() => JSON.parse(io.outBuf())).not.toThrow()
})
// ── sniffOutputFormat-o flag 解析)─────────────────────────────────────
it('sniffOutputFormat 正确解析 -o json空格形式[P0]', () => {
expect(sniffOutputFormat(['get', 'app', '-o', 'json'])).toBe('json')
})
it('sniffOutputFormat 正确解析 --output=yaml等号形式[P0]', () => {
expect(sniffOutputFormat(['get', 'app', '--output=yaml'])).toBe('yaml')
})
it('sniffOutputFormat 无 -o flag 时返回空字符串 [P0]', () => {
expect(sniffOutputFormat(['get', 'app'])).toBe('')
})
it('sniffOutputFormat 在 -- 之后的 flag 不被解析 [P0]', () => {
expect(sniffOutputFormat(['cmd', '--', '-o', 'json'])).toBe('')
})
it('sniffOutputFormat 对 --output 大小写敏感(--OUTPUT 不被识别)[P1]', () => {
expect(sniffOutputFormat(['cmd', '--OUTPUT=json'])).toBe('')
})
it('重复 -o flag 以第一个为准 [P1]', () => {
expect(sniffOutputFormat(['cmd', '-o', 'json', '-o', 'yaml'])).toBe('json')
})
// ── -w / --workspace flag ─────────────────────────────────────────────────
it('-w workspace flag 覆盖默认 workspaceget app 切换到 ws-2[P0]', async () => {
const result = await runGetApp({ workspace: 'ws-2' }, { bundle: baseBundle, http: http() })
const ids = result.data.rows.map(r => r.data.id)
expect(ids).toContain('app-3')
expect(ids).not.toContain('app-1')
})
it('workspace flag 非法值workspace 不存在)返回空列表 [P0]', async () => {
const result = await runGetApp({ workspace: 'ws-nonexistent' }, { bundle: baseBundle, http: http() })
expect(result.data.rows).toHaveLength(0)
})
// ── --http-retry flag ─────────────────────────────────────────────────────
it('resolveRetryAttemptsflag 优先于 env 变量 [P0]', () => {
expect(resolveRetryAttempts({ flag: 0, env: () => '5' })).toBe(0)
})
it('resolveRetryAttemptsenv 变量为 fallback [P0]', () => {
expect(resolveRetryAttempts({ flag: undefined, env: () => '7' })).toBe(7)
})
it(`resolveRetryAttempts默认值为 ${HTTP_RETRY_DEFAULT} [P0]`, () => {
expect(resolveRetryAttempts({ flag: undefined, env: () => undefined })).toBe(HTTP_RETRY_DEFAULT)
})
it('DIFYCTL_HTTP_RETRY 非数字抛出 UsageInvalidFlag [P0]', () => {
expect(() =>
resolveRetryAttempts({ flag: undefined, env: () => 'abc' }),
).toThrow(/DIFYCTL_HTTP_RETRY/)
})
it('DIFYCTL_HTTP_RETRY 负数抛出 UsageInvalidFlag [P0]', () => {
expect(() =>
resolveRetryAttempts({ flag: undefined, env: () => '-1' }),
).toThrow(/DIFYCTL_HTTP_RETRY/)
})
// ── --help 输出 ───────────────────────────────────────────────────────────
it('formatHelp 包含 USAGE 和 FLAGS 两个章节 [P0]', () => {
const out = formatHelp(RunApp, 'run app')
expect(out).toContain('USAGE')
expect(out).toContain('FLAGS')
})
it('formatHelp 包含 --inputs 和 --stream 等命令级 flag [P1]', () => {
const out = formatHelp(RunApp, 'run app')
expect(out).toContain('--inputs')
expect(out).toContain('--stream')
})
it('formatHelp 包含 EXAMPLES 区域 [P1]', () => {
const out = formatHelp(RunApp, 'run app')
expect(out).toContain('EXAMPLES')
expect(out).toContain('difyctl run app')
})
it('get app formatHelp 包含 --output 和 --workspace flag [P1]', () => {
const out = formatHelp(GetApp, 'get app')
expect(out).toContain('--output')
expect(out).toContain('--workspace')
})
it('formatHelp 输出不含 ANSI 颜色控制字符 [P1]', () => {
const out = formatHelp(RunApp, 'run app')
expect(hasAnsi(out)).toBe(false)
})
it('formatHelp 输出末尾为 \\n [P1]', () => {
const out = formatHelp(RunApp, 'run app')
expect(out.endsWith('\n')).toBe(true)
})
// ── --version flag ────────────────────────────────────────────────────────
it('Version 命令 run([]) 返回 formatted 类型输出 [P0]', async () => {
const probe = await import('../../../src/version/probe.js')
vi.spyOn(probe, 'runVersionProbe').mockResolvedValue({
client: { version: '0.0.0-test', commit: '0000000', buildDate: '1970-01-01T00:00:00.000Z', channel: 'dev', platform: 'test', arch: 'test' },
server: { endpoint: '', reachable: false },
compat: { minDify: '1.6.0', maxDify: '1.7.0', status: 'unknown', detail: '' },
})
try {
const output = await new Version().run([])
expect(output?.kind).toBe('formatted')
}
finally {
vi.restoreAllMocks()
}
})
it('version --short 返回 raw 类型输出 [P1]', async () => {
const probe = await import('../../../src/version/probe.js')
vi.spyOn(probe, 'runVersionProbe').mockResolvedValue({
client: { version: '0.0.0-test', commit: '0000000', buildDate: '1970-01-01T00:00:00.000Z', channel: 'dev', platform: 'test', arch: 'test' },
server: { endpoint: '', reachable: false },
compat: { minDify: '1.6.0', maxDify: '1.7.0', status: 'unknown', detail: '' },
})
try {
const output = await new Version().run(['--short'])
expect(output?.kind).toBe('raw')
}
finally {
vi.restoreAllMocks()
}
})
// ── 错误路由(与 run.test.ts 补充差异的场景)──────────────────────────────
it('非法 global flag--invalid抛出错误 [P0]', async () => {
// 在真实命令中传入未知 flag → parseArgv 抛错
// RunApp 的 parse 会拒绝未知 flag
await expect(
new RunApp().run(['app-1', '--invalid-unknown-flag']),
).rejects.toThrow()
})
it('非法 flag exit code 通过 BaseError exit() 返回 2Usage[P0]', async () => {
// parseArgv 抛出 Usage 类型错误
const { parseArgv } = await import('../../../src/framework/flags.js')
const { Flags } = await import('../../../src/framework/flags.js')
const meta = { flags: { output: Flags.string({ description: 'fmt', char: 'o' }) }, args: {} }
expect(() => parseArgv(['--unknown-flag'], meta)).toThrow()
})
it('formatErrorForCli 在 JSON 模式输出合法 JSON error envelope [P1]', async () => {
mock.setScenario('server-5xx')
try {
await runGetApp({ format: 'json' }, { bundle: baseBundle, http: http() })
}
catch (e) {
if (e instanceof BaseError) {
const out = formatErrorForCli(e, { format: 'json' })
expect(() => JSON.parse(out)).not.toThrow()
const parsed = JSON.parse(out) as { error: { code: string } }
expect(parsed.error.code).toBe(e.code)
}
}
})
// ── 已知缺陷标注 ──────────────────────────────────────────────────────────
it('top-level --help 输出包含 auth devices 描述文字(组命令也有说明)[P1]', async () => {
const { commandTree } = await import('../../../src/commands/tree.js')
const { run } = await import('../../../src/framework/run.js')
const chunks: string[] = []
const spy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown) => {
chunks.push(String(chunk))
return true
}) as never)
try {
await run(commandTree, ['--help'])
}
finally {
spy.mockRestore()
}
const out = chunks.join('')
expect(out).toMatch(/\bauth\b/)
expect(out).toMatch(/\bdevices\b/)
expect(out).toMatch(/devices\s+2 subcommands/)
})
it('top-level --help 输出包含 GLOBAL FLAGS 章节(-o/--output、--workspace、--http-retry[P1]', async () => {
const { commandTree } = await import('../../../src/commands/tree.js')
const { run } = await import('../../../src/framework/run.js')
const chunks: string[] = []
const spy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown) => {
chunks.push(String(chunk))
return true
}) as never)
try {
await run(commandTree, ['--help'])
}
finally {
spy.mockRestore()
}
const out = chunks.join('')
expect(out).toContain('GLOBAL FLAGS')
expect(out).toContain('--output')
expect(out).toContain('--workspace')
expect(out).toContain('--http-retry')
})
it('top-level --help 输出包含 Quick start 示例auth login → get app → run app[P1]', async () => {
const { commandTree } = await import('../../../src/commands/tree.js')
const { run } = await import('../../../src/framework/run.js')
const chunks: string[] = []
const spy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown) => {
chunks.push(String(chunk))
return true
}) as never)
try {
await run(commandTree, ['--help'])
}
finally {
spy.mockRestore()
}
const out = chunks.join('')
expect(out).toContain('QUICK START')
expect(out).toContain('difyctl auth login')
expect(out).toContain('difyctl get app')
expect(out).toContain('difyctl run app')
})
})

View File

@ -0,0 +1,260 @@
/**
* Dify CLI/CLI Framework/Non-Interactive 集成测试
*
* 用例来源飞书文档《Dify CLI Enhanced》— Dify CLI/CLI Framework/Non-Interactive30 条)
*
* 覆盖策略:
* - 通过 bufferStreamsisOutTTY=false, isErrTTY=false模拟非 TTY 环境
* - 验证 ANSI 颜色关闭、无 spinner、JSON/YAML 输出纯净、stderr/stdout 隔离
* - 非 TTY 环境下命令正常执行、exit code 正确
*/
import type { ExitCodeValue } from '../../../src/errors/codes.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 { runGetApp } from '../../../src/commands/get/app/run.js'
import { runApp } from '../../../src/commands/run/app/run.js'
import { BaseError } from '../../../src/errors/base.js'
import { ExitCode } from '../../../src/errors/codes.js'
import { stringifyOutput, table } from '../../../src/framework/output.js'
import { createClient } from '../../../src/http/client.js'
import { colorEnabled, colorScheme } from '../../../src/io/color.js'
import { bufferStreams } from '../../../src/io/streams.js'
import { hostsBundleFixture } from '../../fixtures/dify-mock/scenarios.js'
import { startMock } from '../../fixtures/dify-mock/server.js'
// eslint-disable-next-line no-control-regex
const ANSI_RE = /\x1B\[[0-9;]*[mGKHFA-DJsuhl]/
const hasAnsi = (s: string) => ANSI_RE.test(s)
const baseBundle = hostsBundleFixture({ includeAllWorkspaces: true })
describe('Dify CLI/CLI Framework/Non-Interactive', () => {
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-noninteractive-'))
})
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,
})
}
// ── ANSI color 控制 ───────────────────────────────────────────────────────
it('非 TTY 环境isErrTTY=falsecolorEnabled 返回 false [P0]', () => {
expect(colorEnabled(false)).toBe(false)
expect(colorEnabled(true)).toBe(true)
})
it('colorEnabled=false 时 colorScheme 所有方法为 identity无 ANSI[P0]', () => {
const cs = colorScheme(false)
const text = 'hello'
expect(cs.bold(text)).toBe(text)
expect(cs.dim(text)).toBe(text)
expect(cs.cyan(text)).toBe(text)
expect(cs.magenta(text)).toBe(text)
expect(hasAnsi(cs.bold(text))).toBe(false)
expect(hasAnsi(cs.cyan(text))).toBe(false)
})
it('非 TTY 环境 table 输出不含 ANSI color [P0]', async () => {
const result = await runGetApp({}, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: '', data: result.data }))
expect(hasAnsi(out)).toBe(false)
})
it('非 TTY 环境 JSON 输出不含 ANSI color [P0]', async () => {
const result = await runGetApp({ format: 'json' }, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: 'json', data: result.data }))
expect(hasAnsi(out)).toBe(false)
})
it('非 TTY 环境 YAML 输出不含 ANSI color [P0]', async () => {
const result = await runGetApp({ format: 'yaml' }, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: 'yaml', data: result.data }))
expect(hasAnsi(out)).toBe(false)
})
// ── spinner 行为 ──────────────────────────────────────────────────────────
it('非 TTY 环境isErrTTY=falsespinner 不输出到 stderr [P0]', async () => {
// bufferStreams() 默认 isErrTTY=false → spinner 被禁用NOOP_SPINNER
const io = bufferStreams()
await runGetApp({}, { bundle: baseBundle, http: http(), io })
// 如果有 spinnererrBuf 会包含 ANSI 序列;非 TTY 应为空或仅含 hint
expect(hasAnsi(io.errBuf())).toBe(false)
})
it('非 TTY 环境 stream run 输出无 spinner ANSI [P1]', async () => {
const io = bufferStreams()
const c = await cache()
await runApp(
{ appId: 'app-1', message: 'hi', stream: true },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: c },
)
expect(hasAnsi(io.outBuf())).toBe(false)
})
// ── 命令正常执行 ──────────────────────────────────────────────────────────
it('非 TTY 环境命令正常执行get app 返回正确数据)[P0]', async () => {
const io = bufferStreams()
const result = await runGetApp({}, { bundle: baseBundle, http: http(), io })
expect(result.data.rows.length).toBeGreaterThan(0)
})
it('非 TTY 环境 JSON 输出可正常解析pipe 友好)[P0]', async () => {
const result = await runGetApp({ format: 'json' }, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: 'json', data: result.data }))
expect(() => JSON.parse(out)).not.toThrow()
const parsed = JSON.parse(out) as { data: unknown[] }
expect(Array.isArray(parsed.data)).toBe(true)
})
it('非 TTY 环境 YAML 输出可正常解析 [P1]', async () => {
const { default: yaml } = await import('js-yaml')
const result = await runGetApp({ format: 'yaml' }, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: 'yaml', data: result.data }))
expect(() => yaml.load(out)).not.toThrow()
})
it('stderr 日志不污染 stdoutbufferStreams 分离)[P0]', async () => {
const io = bufferStreams()
const c = await cache()
await runApp(
{ appId: 'app-1', message: 'test' },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: c },
)
// stdout 只含答案
expect(io.outBuf()).toContain('echo:')
// stderr 可能含 hint但不含答案内容
expect(io.errBuf()).not.toContain('echo: test')
})
it('非交互模式错误立即返回(不 hangstderr 有错误信息 [P0]', async () => {
mock.setScenario('server-5xx')
const io = bufferStreams()
const start = Date.now()
try {
await runGetApp({}, { bundle: baseBundle, http: http(), io })
}
catch { /* expected */ }
// 应在 5s 内完成(不阻塞等待用户输入)
expect(Date.now() - start).toBeLessThan(5000)
})
it('非交互模式 exit code 正确(成功=0[P0]', async () => {
let code: ExitCodeValue = ExitCode.Success
try {
await runGetApp({}, { bundle: baseBundle, http: http() })
}
catch (e) {
if (e instanceof BaseError)
code = e.exit()
}
expect(code).toBe(ExitCode.Success)
})
it('非交互模式 exit code 正确auth error=4[P0]', async () => {
mock.setScenario('auth-expired')
try {
await runGetApp({}, { bundle: baseBundle, http: http() })
}
catch (e) {
if (e instanceof BaseError)
expect(e.exit()).toBe(ExitCode.Auth)
}
})
it('非交互模式 workspace 切换正常(-w flag 生效)[P1]', async () => {
const result = await runGetApp({ workspace: 'ws-2' }, { bundle: baseBundle, http: http() })
const ids = result.data.rows.map(r => r.data.id)
expect(ids).toContain('app-3')
expect(ids).not.toContain('app-1')
})
// ── shell pipe 支持 ───────────────────────────────────────────────────────
it('shell pipe 支持(-o json 输出可被进一步解析)[P0]', async () => {
const result = await runGetApp({ format: 'json' }, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: 'json', data: result.data }))
// 模拟 jq .data[0].id
const parsed = JSON.parse(out) as { data: Array<{ id: string }> }
expect(parsed.data[0]?.id).toBeDefined()
})
it('JSON 输出末尾为 \\n适合 pipe 管道处理 [P0]', async () => {
const result = await runGetApp({ format: 'json' }, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: 'json', data: result.data }))
expect(out.endsWith('\n')).toBe(true)
})
it('大量输出在 pipe 场景稳定all-workspaces 4 个 app[P1]', async () => {
const result = await runGetApp({ allWorkspaces: true, format: 'json' }, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: 'json', data: result.data }))
const parsed = JSON.parse(out) as { data: unknown[] }
expect(parsed.data).toHaveLength(4)
})
// ── 非 TTY 环境错误 JSON envelope ─────────────────────────────────────────
it('非交互模式错误 JSON envelope 正常server-5xx 抛 BaseError[P1]', async () => {
mock.setScenario('server-5xx')
const { toEnvelope } = await import('../../../src/errors/envelope.js')
try {
await runGetApp({ format: 'json' }, { bundle: baseBundle, http: http() })
}
catch (e) {
if (e instanceof BaseError) {
const env = toEnvelope(e)
const json = JSON.stringify(env)
expect(() => JSON.parse(json)).not.toThrow()
expect(env.error.code).toBeTruthy()
}
}
})
// ── stream 模式 ───────────────────────────────────────────────────────────
it('stream 模式在非 TTY 环境正常输出bufferStreams[P1]', async () => {
const io = bufferStreams()
const c = await cache()
await runApp(
{ appId: 'app-1', message: 'non-tty-stream', stream: true },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: c },
)
expect(io.outBuf()).toContain('echo:')
expect(io.outBuf()).toContain('non-tty-stream')
})
it('网络错误在非 TTY 环境正常返回(不阻塞)[P1]', async () => {
mock.setScenario('server-5xx')
const start = Date.now()
try {
await runGetApp({}, { bundle: baseBundle, http: http() })
}
catch { /* expected */ }
expect(Date.now() - start).toBeLessThan(5000)
})
})

View File

@ -0,0 +1,273 @@
/**
* Discovery / Describe App 集成测试
*
* 用例来源飞书文档《Dify CLI Enhanced》— Dify CLI/Discovery/Describe App29 条)
* 命令difyctl describe app <id>
* 测试范式:模式 A依赖注入—— startMock() + runDescribeApp()
*/
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 { formatted, stringifyOutput } from '../../../../../src/framework/output.js'
import { createClient } from '../../../../../src/http/client.js'
import { hostsBundleFixture } from '../../../../fixtures/dify-mock/scenarios.js'
import { startMock } from '../../../../fixtures/dify-mock/server.js'
// ── shared fixtures ──────────────────────────────────────────────────────────
const baseBundle = hostsBundleFixture({ includeAllWorkspaces: true })
const ssoBundle: HostsBundle = {
current_host: 'http://localhost',
token_storage: 'file',
tokens: { bearer: 'dfoe_test' },
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
}
// ── suite ────────────────────────────────────────────────────────────────────
describe('Discovery / Describe App', () => {
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-desc-test-'))
})
afterEach(async () => {
await rm(dir, { recursive: true, force: true })
})
afterAll(async () => {
await mock.stop()
})
function http(bearer = 'dfoa_test') {
return createClient({ host: mock.url, bearer, retryAttempts: 0 })
}
async function render(
opts: Parameters<typeof runDescribeApp>[0],
bearer = 'dfoa_test',
): Promise<string> {
const cache = await loadAppInfoCache({ configDir: dir })
const data = await runDescribeApp(
opts,
{ bundle: baseBundle, http: http(bearer), host: mock.url, cache },
)
return stringifyOutput(formatted({ format: opts.format ?? '', data }))
}
// ── 基础行为 ─────────────────────────────────────────────────────────────
it('已登录用户可 describe app返回 app 详情 [P0]', async () => {
const out = await render({ appId: 'app-1' })
expect(out.length).toBeGreaterThan(0)
expect(out).toContain('Greeter')
})
it('describe app 调用 describe endpointGET /openapi/v1/apps/<id>/describe[P0]', async () => {
// 通过 AppMetaClient 调用,验证 mock 服务端 describe 路由正常响应
const cache = await loadAppInfoCache({ configDir: dir })
const data = await runDescribeApp(
{ appId: 'app-1' },
{ bundle: baseBundle, http: http(), host: mock.url, cache },
)
expect(data.payload.info?.id).toBe('app-1')
})
// ── 默认 text 输出(标签式分节)────────────────────────────────────────────
it('默认 text 输出为标签式分节结构kubectl-describe 风格)[P0]', async () => {
const out = await render({ appId: 'app-1' })
// 验证有对齐的 Key: Value 行
expect(out).toMatch(/\w+:\s+\S+/)
})
it('describe 输出包含 ID [P1]', async () => {
const out = await render({ appId: 'app-1' })
expect(out).toContain('ID:')
expect(out).toContain('app-1')
})
it('describe 输出包含 Mode [P1]', async () => {
const out = await render({ appId: 'app-1' })
expect(out).toContain('Mode:')
expect(out).toContain('chat')
})
it('describe 输出包含 Name [P1]', async () => {
const out = await render({ appId: 'app-1' })
expect(out).toContain('Name:')
expect(out).toContain('Greeter')
})
it('describe 输出包含 Descriptionapp 有 description 时)[P1]', async () => {
// app-1 description = 'A simple greeting bot'
const out = await render({ appId: 'app-1' })
expect(out).toContain('Description:')
expect(out).toContain('A simple greeting bot')
})
it('describe 输出包含 Author [P1]', async () => {
const out = await render({ appId: 'app-1' })
expect(out).toContain('Author:')
expect(out).toContain('tester')
})
it('describe 输出包含 Tags [P1]', async () => {
const out = await render({ appId: 'app-1' })
expect(out).toContain('Tags:')
expect(out).toContain('demo')
})
it('describe 输出包含 InputsParameters分节 [P0]', async () => {
// app-1 有 parameters
const out = await render({ appId: 'app-1' })
expect(out).toContain('Parameters:')
})
it('Inputs 显示参数名 [P0]', async () => {
// app-1 parameters.user_input_form 包含 variable=name
const out = await render({ appId: 'app-1' })
expect(out).toContain('name')
})
it('Inputs 显示参数类型 [P0]', async () => {
const out = await render({ appId: 'app-1' })
// text-input 类型
expect(out).toContain('text-input')
})
it('Inputs 显示 required/optional [P0]', async () => {
const out = await render({ appId: 'app-1' })
// required: true
expect(out).toContain('required')
})
it('agent app 输出包含 Agent: true [P1]', async () => {
const out = await render({ appId: 'app-4', workspace: 'ws-2' })
expect(out).toContain('Agent:')
expect(out).toContain('true')
})
// ── 输出格式 ─────────────────────────────────────────────────────────────
it('-o json 返回原始服务端响应info + parameters + input_schema[P0]', async () => {
const out = await render({ appId: 'app-1', format: 'json' })
const parsed = JSON.parse(out) as { info: { id: string }, parameters: unknown }
expect(parsed.info.id).toBe('app-1')
expect(parsed.parameters).toBeDefined()
})
it('JSON 输出为合法缩进 JSON可解析且格式化[P1]', async () => {
const out = await render({ appId: 'app-1', format: 'json' })
expect(() => JSON.parse(out)).not.toThrow()
// 有缩进(包含 " "
expect(out).toContain(' ')
})
it('-o yaml 输出合法 YAML [P1]', async () => {
const out = await render({ appId: 'app-1', format: 'yaml' })
expect(out).toContain('info:')
expect(out).toContain('id: app-1')
})
it('describe app 不支持 wide 输出,返回 "not supported" 错误 [P0]', async () => {
await expect(render({ appId: 'app-1', format: 'wide' })).rejects.toThrow(/not supported/)
})
it('describe app 不支持 name 输出,返回 "not supported" 错误 [P0]', async () => {
await expect(render({ appId: 'app-1', format: 'name' })).rejects.toThrow(/name output requires|not supported/)
})
it('describe 输出支持 pipe-o json 输出首字符为 {[P1]', async () => {
const out = await render({ appId: 'app-1', format: 'json' })
expect(out.trim().startsWith('{')).toBe(true)
})
// ── 错误场景 ──────────────────────────────────────────────────────────────
it('describe 不存在 app 返回错误 [P0]', async () => {
await expect(render({ appId: 'app-nonexistent' })).rejects.toThrow()
})
it('describe 不存在 app exit code 为 1Generic[P0]', async () => {
const { BaseError } = await import('../../../../../src/errors/base.js')
const cache = await loadAppInfoCache({ configDir: dir })
try {
await runDescribeApp(
{ appId: 'app-nonexistent' },
{ bundle: baseBundle, http: http(), host: mock.url, cache },
)
expect.fail('should throw')
}
catch (e) {
expect(e instanceof BaseError).toBe(true)
expect((e as InstanceType<typeof BaseError>).exit()).toBe(1)
}
})
it('未登录执行 describe 返回认证错误 [P0]', async () => {
mock.setScenario('auth-expired')
await expect(render({ appId: 'app-1' })).rejects.toThrow()
})
it('未登录 describe exit code 为 4Auth[P0]', async () => {
mock.setScenario('auth-expired')
const { BaseError } = await import('../../../../../src/errors/base.js')
const cache = await loadAppInfoCache({ configDir: dir })
try {
await runDescribeApp(
{ appId: 'app-1' },
{ bundle: baseBundle, http: http(), host: mock.url, cache },
)
expect.fail('should throw')
}
catch (e) {
expect(e instanceof BaseError).toBe(true)
expect((e as InstanceType<typeof BaseError>).exit()).toBe(4)
}
})
it('外部 SSO 用户 describe 返回 insufficient_scope无 workspace[P0]', async () => {
const cache = await loadAppInfoCache({ configDir: dir })
await expect(
runDescribeApp(
{ appId: 'app-1' },
{ bundle: ssoBundle, http: http('dfoe_test'), host: mock.url, cache },
),
).rejects.toThrow(/no workspace|insufficient/)
})
it('网络异常 describe 返回 server/network error [P1]', async () => {
mock.setScenario('server-5xx')
await expect(render({ appId: 'app-1' })).rejects.toThrow()
})
it('JSON 模式错误输出 JSON envelope错误为 BaseError[P1]', async () => {
mock.setScenario('server-5xx')
const { BaseError } = await import('../../../../../src/errors/base.js')
try {
await render({ appId: 'app-1', format: 'json' })
expect.fail('should throw')
}
catch (e) {
expect(e instanceof BaseError).toBe(true)
}
})
it('缺少 app id 时runDescribeApp 需要 appId否则 workspace 解析失败 [P1]', async () => {
// appId 为空字符串时describe client 会查询空 id服务端返回 404
await expect(render({ appId: '' })).rejects.toThrow()
})
})

View File

@ -0,0 +1,227 @@
/**
* Discovery / 跨 Workspace 查询 集成测试
*
* 用例来源飞书文档《Dify CLI Enhanced》— Dify CLI/Discovery/跨 Workspace 查询22 条)
* 命令difyctl get app -A / --all-workspaces
* 测试范式:模式 A依赖注入—— startMock() + runGetApp({ allWorkspaces: true })
*/
import type { HostsBundle } from '../../../../../src/auth/hosts.js'
import type { DifyMock } from '../../../../fixtures/dify-mock/server.js'
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { runGetApp } from '../../../../../src/commands/get/app/run.js'
import { stringifyOutput, table } from '../../../../../src/framework/output.js'
import { createClient } from '../../../../../src/http/client.js'
import { hostsBundleFixture } from '../../../../fixtures/dify-mock/scenarios.js'
import { startMock } from '../../../../fixtures/dify-mock/server.js'
// ── shared fixtures ──────────────────────────────────────────────────────────
const baseBundle = hostsBundleFixture({ includeAllWorkspaces: true })
// SSO bundle外部 SSO 用户,无 workspace
const ssoBundle: HostsBundle = {
current_host: '127.0.0.1',
scheme: 'http',
token_storage: 'file',
tokens: { bearer: 'dfoe_test' },
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
}
// ── suite ────────────────────────────────────────────────────────────────────
describe('Discovery / 跨 Workspace 查询(-A', () => {
let mock: DifyMock
beforeAll(async () => {
mock = await startMock({ scenario: 'happy' })
})
beforeEach(() => {
mock.setScenario('happy')
mock.reset()
})
afterAll(async () => {
await mock.stop()
})
function http(bearer = 'dfoa_test') {
return createClient({ host: mock.url, bearer, retryAttempts: 0 })
}
async function render(
opts: Parameters<typeof runGetApp>[0] = {},
bundle: HostsBundle = baseBundle,
): Promise<string> {
const result = await runGetApp(opts, { bundle, http: http() })
return stringifyOutput(table({ format: opts.format ?? '', data: result.data }))
}
// ── 基础行为 ─────────────────────────────────────────────────────────────
it('内部用户可执行 all-workspaces 查询,返回多个 workspace 的 app [P0]', async () => {
const out = await render({ allWorkspaces: true })
expect(out).toContain('app-1') // ws-1
expect(out).toContain('app-2') // ws-1
expect(out).toContain('app-3') // ws-2
expect(out).toContain('app-4') // ws-2
})
it('fan-out 查询覆盖 available_workspacestotal 为所有 workspace app 之和 [P0]', async () => {
const result = await runGetApp({ allWorkspaces: true }, { bundle: baseBundle, http: http() })
// ws-1 有 2 个 appws-2 有 2 个 app共 4 个
expect(result.data.envelope.total).toBe(4)
expect(result.data.rows).toHaveLength(4)
})
it('--all-workspaces 与 -A 行为一致(同一选项两种写法)[P1]', async () => {
// 在代码层面两者映射为同一 opts.allWorkspaces验证结果相同
const r1 = await runGetApp({ allWorkspaces: true }, { bundle: baseBundle, http: http() })
const r2 = await runGetApp({ allWorkspaces: true }, { bundle: baseBundle, http: http() })
expect(r1.data.envelope.total).toBe(r2.data.envelope.total)
expect(r1.data.rows.map(r => r.data.id).sort())
.toEqual(r2.data.rows.map(r => r.data.id).sort())
})
it('结果按 app id 字典序排列fan-out 后 merge sort[P0]', async () => {
const result = await runGetApp({ allWorkspaces: true }, { bundle: baseBundle, http: http() })
const ids = result.data.rows.map(r => r.data.id)
const sorted = [...ids].sort((a, b) => a.localeCompare(b))
expect(ids).toEqual(sorted)
})
// ── 表格/输出格式 ─────────────────────────────────────────────────────────
it('table 输出包含 WORKSPACE 列(-o wide[P0]', async () => {
const out = await render({ allWorkspaces: true, format: 'wide' })
expect(out).toMatch(/WORKSPACE/)
})
it('WORKSPACE 列显示 workspace 名称Default / Other[P1]', async () => {
const out = await render({ allWorkspaces: true, format: 'wide' })
expect(out).toContain('Default')
expect(out).toContain('Other')
})
it('JSON 输出包含 workspace_id 字段 [P0]', async () => {
const out = await render({ allWorkspaces: true, format: 'json' })
const parsed = JSON.parse(out) as { data: Array<{ workspace_id?: string }> }
expect(parsed.data.every(r => r.workspace_id !== undefined)).toBe(true)
})
it('YAML 输出包含 workspace_id [P1]', async () => {
const out = await render({ allWorkspaces: true, format: 'yaml' })
expect(out).toContain('workspace_id:')
})
it('all-workspaces 输出支持 pipe-o name 每行一个 id[P1]', async () => {
const out = await render({ allWorkspaces: true, format: 'name' })
const lines = out.trim().split('\n').sort()
expect(lines).toEqual(['app-1', 'app-2', 'app-3', 'app-4'])
})
// ── 参数组合 ──────────────────────────────────────────────────────────────
it('limit 参数在 all-workspaces 下对每个 workspace 生效 [P1]', async () => {
// limit=1每个 workspace 最多返回 1 条2 个 workspace 共 2 条
const result = await runGetApp(
{ allWorkspaces: true, limitRaw: '1' },
{ bundle: baseBundle, http: http() },
)
expect(result.data.rows).toHaveLength(2)
})
it('mode 过滤在 all-workspaces 下生效 [P1]', async () => {
const result = await runGetApp(
{ allWorkspaces: true, mode: 'workflow' },
{ bundle: baseBundle, http: http() },
)
// 只有 app-2 是 workflow
expect(result.data.rows).toHaveLength(1)
expect(result.data.rows[0]?.data.mode).toBe('workflow')
})
it('同时使用 -A 与 -w 时,-A 优先:-w 被 allWorkspaces 路径忽略 [P1]', async () => {
// 代码中 allWorkspaces 分支不走 resolveWorkspaceId不报错
const result = await runGetApp(
{ allWorkspaces: true, workspace: 'ws-1' },
{ bundle: baseBundle, http: http() },
)
// 仍然 fan-out 所有 workspace
expect(result.data.rows.length).toBeGreaterThanOrEqual(2)
})
it('空 workspace 集合返回空列表sso 场景服务端返回空 workspaces[P1]', async () => {
// sso 场景下 /workspaces 返回空列表fan-out 没有目标
mock.setScenario('sso')
const result = await runGetApp(
{ allWorkspaces: true },
{ bundle: baseBundle, http: http() },
)
expect(result.data.rows).toHaveLength(0)
expect(result.data.envelope.total).toBe(0)
})
// ── 错误场景 ──────────────────────────────────────────────────────────────
it('外部 SSO 用户执行 -A 在 auth-expired 场景返回认证错误 [P0]', async () => {
// allWorkspaces 路径调 ws.list(),在 auth-expired 场景401下抛错
mock.setScenario('auth-expired')
await expect(
runGetApp({ allWorkspaces: true }, { bundle: ssoBundle, http: http('dfoe_test') }),
).rejects.toThrow()
})
it('外部 SSO 用户 -A exit code 为 1 [P0]', async () => {
const { BaseError } = await import('../../../../../src/errors/base.js')
try {
await runGetApp({ allWorkspaces: true }, { bundle: ssoBundle, http: http('dfoe_test') })
expect.fail('should throw')
}
catch (e) {
// no workspace → UsageMissingArg or Generic, both exit 1 or 2
if (e instanceof BaseError)
expect(e.exit()).toBeGreaterThanOrEqual(1)
else
expect(e).toBeTruthy() // still an error
}
})
it('未登录执行 -A 返回认证错误 [P0]', async () => {
mock.setScenario('auth-expired')
await expect(
runGetApp({ allWorkspaces: true }, { bundle: baseBundle, http: http() }),
).rejects.toThrow()
})
it('未登录 -A exit code 为 4Auth[P0]', async () => {
mock.setScenario('auth-expired')
const { BaseError } = await import('../../../../../src/errors/base.js')
try {
await runGetApp({ allWorkspaces: true }, { bundle: baseBundle, http: http() })
expect.fail('should throw')
}
catch (e) {
expect(e instanceof BaseError).toBe(true)
expect((e as InstanceType<typeof BaseError>).exit()).toBe(4)
}
})
it('网络异常时返回 server/network error [P1]', async () => {
mock.setScenario('server-5xx')
await expect(
runGetApp({ allWorkspaces: true }, { bundle: baseBundle, http: http() }),
).rejects.toThrow()
})
it('JSON 模式错误输出 JSON envelope抛出 BaseError[P1]', async () => {
mock.setScenario('server-5xx')
const { BaseError } = await import('../../../../../src/errors/base.js')
try {
await runGetApp({ allWorkspaces: true }, { bundle: baseBundle, http: http() })
expect.fail('should throw')
}
catch (e) {
expect(e instanceof BaseError).toBe(true)
}
})
})

View File

@ -0,0 +1,452 @@
/**
* Discovery / App 列表 集成测试
*
* 覆盖 `difyctl get app` (runGetApp) 的列表行为。
* 测试用例来源飞书文档《Dify CLI Enhanced》— Discovery / App 列表
*
* 测试框架Vitest + dify-mock fixture server
* 范式:模式 A依赖注入通过 startMock() 启动本地 HTTP Mock
* 注入 createClient / bundle / io直接调用 runGetApp()。
*/
import type { HostsBundle } from '../../../../../src/auth/hosts.js'
import type { DifyMock } from '../../../../fixtures/dify-mock/server.js'
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { runGetApp } from '../../../../../src/commands/get/app/run.js'
import { stringifyOutput, table } from '../../../../../src/framework/output.js'
import { createClient } from '../../../../../src/http/client.js'
import { LIMIT_DEFAULT, LIMIT_MAX, LIMIT_MIN } from '../../../../../src/limit/limit.js'
import { hostsBundleFixture } from '../../../../fixtures/dify-mock/scenarios.js'
import { startMock } from '../../../../fixtures/dify-mock/server.js'
// ---------------------------------------------------------------------------
// 共用 fixture
// ---------------------------------------------------------------------------
const baseBundle = hostsBundleFixture({ includeAllWorkspaces: true })
// ---------------------------------------------------------------------------
// Discovery / App 列表
// ---------------------------------------------------------------------------
describe('Discovery / App 列表', () => {
let mock: DifyMock
beforeAll(async () => {
mock = await startMock({ scenario: 'happy' })
})
beforeEach(() => {
mock.setScenario('happy')
mock.reset()
})
afterAll(async () => {
await mock.stop()
})
function http() {
return createClient({ host: mock.url, bearer: 'dfoa_test' })
}
/** 渲染为字符串,方便断言输出内容 */
async function render(opts: Parameters<typeof runGetApp>[0] = {}): Promise<string> {
const result = await runGetApp(opts, { bundle: baseBundle, http: http() })
return stringifyOutput(table({
format: opts.format ?? '',
data: result.data,
}))
}
// =========================================================================
// 基础列表
// =========================================================================
describe('基础列表', () => {
it('TC-LIST-001: 默认列出当前工作区的全部 App表头包含 NAME ID MODE TAGS UPDATED', async () => {
const out = await render()
expect(out).toMatch(/NAME\s+ID\s+MODE\s+TAGS\s+UPDATED/)
})
it('TC-LIST-002: 默认只返回当前工作区ws-1的 App不包含其他工作区的 App', async () => {
const out = await render()
// ws-1 中的 app-1(Greeter/chat) 和 app-2(Workflow/workflow)
expect(out).toContain('Greeter')
expect(out).toContain('app-1')
expect(out).toContain('Workflow')
expect(out).toContain('app-2')
// ws-2 中的 app-3(OtherWS Bot) 不应出现
expect(out).not.toContain('app-3')
expect(out).not.toContain('OtherWS Bot')
})
it('TC-LIST-003: 列表行包含 mode 字段chat 和 workflow 均能正确显示', async () => {
const out = await render()
expect(out).toContain('chat')
expect(out).toContain('workflow')
})
it('TC-LIST-004: 列表行包含 tags 字段,带 tag 的 App 显示 tag 名称', async () => {
const out = await render()
// app-1 (Greeter) 有 tag: demo
expect(out).toContain('demo')
})
it('TC-LIST-005: 列表行包含 updated_at 字段', async () => {
const out = await render()
// app-1 updated_at = 2026-01-02T00:00:00Z
expect(out).toContain('2026-01-02')
})
it('TC-LIST-006: 无 App 时返回空表0 行数据)', async () => {
// ws-3 不存在,服务器返回空列表
const result = await runGetApp(
{ workspace: 'ws-nonexistent' },
{ bundle: baseBundle, http: http() },
)
expect(result.data.rows).toHaveLength(0)
expect(result.data.envelope.total).toBe(0)
})
})
// =========================================================================
// 按 mode 过滤
// =========================================================================
describe('--mode 过滤', () => {
it('TC-FILTER-MODE-001: --mode workflow 只返回 workflow 类型的 App', async () => {
const out = await render({ mode: 'workflow' })
expect(out).toContain('Workflow')
expect(out).toContain('app-2')
expect(out).not.toContain('Greeter')
expect(out).not.toContain('app-1')
})
it('TC-FILTER-MODE-002: --mode chat 只返回 chat 类型的 App', async () => {
const out = await render({ mode: 'chat' })
expect(out).toContain('Greeter')
expect(out).not.toContain('Workflow')
})
it('TC-FILTER-MODE-003: --mode 传入不存在的 mode 时返回空结果', async () => {
const result = await runGetApp(
{ mode: 'nonexistent-mode' },
{ bundle: baseBundle, http: http() },
)
expect(result.data.rows).toHaveLength(0)
})
})
// =========================================================================
// 按 tag 过滤
// =========================================================================
describe('--tag 过滤', () => {
it('TC-FILTER-TAG-001: --tag demo 只返回带 demo 标签的 App', async () => {
const out = await render({ tag: 'demo' })
expect(out).toContain('Greeter')
expect(out).not.toContain('Workflow')
})
it('TC-FILTER-TAG-002: --tag 传入不存在的 tag 时返回空结果', async () => {
const result = await runGetApp(
{ tag: 'nonexistent-tag' },
{ bundle: baseBundle, http: http() },
)
expect(result.data.rows).toHaveLength(0)
})
})
// =========================================================================
// 按 name 过滤
// =========================================================================
describe('--name 过滤', () => {
it('TC-FILTER-NAME-001: --name Greeter 精确匹配名称', async () => {
const out = await render({ name: 'Greeter' })
expect(out).toContain('Greeter')
expect(out).not.toContain('Workflow')
})
it('TC-FILTER-NAME-002: --name 传入子串时服务器进行模糊匹配', async () => {
// mock 服务器用 includes() 进行名称匹配
const out = await render({ name: 'Greet' })
expect(out).toContain('Greeter')
expect(out).not.toContain('Workflow')
})
it('TC-FILTER-NAME-003: --name 传入不存在的名称时返回空结果', async () => {
const result = await runGetApp(
{ name: 'NonExistentAppXYZ' },
{ bundle: baseBundle, http: http() },
)
expect(result.data.rows).toHaveLength(0)
})
})
// =========================================================================
// 工作区选择
// =========================================================================
describe('工作区选择', () => {
it('TC-WS-001: --workspace ws-2 切换到其他工作区,只显示该工作区的 App', async () => {
const out = await render({ workspace: 'ws-2' })
expect(out).toContain('app-3')
expect(out).toContain('OtherWS Bot')
expect(out).toContain('app-4')
expect(out).toContain('Researcher')
expect(out).not.toContain('Greeter')
expect(out).not.toContain('app-1')
})
it('TC-WS-002: -A (allWorkspaces) 聚合所有工作区的 App按 id 排序', async () => {
const out = await render({ allWorkspaces: true })
expect(out).toContain('app-1')
expect(out).toContain('app-2')
expect(out).toContain('app-3')
expect(out).toContain('app-4')
// 确认按 id 排序app-1 在 app-4 之前
const idxApp1 = out.indexOf('app-1')
const idxApp4 = out.indexOf('app-4')
expect(idxApp1).toBeLessThan(idxApp4)
})
it('TC-WS-003: -A 聚合时 total 为所有工作区 App 数量之和', async () => {
const result = await runGetApp(
{ allWorkspaces: true },
{ bundle: baseBundle, http: http() },
)
// ws-1 有 2 个 Appws-2 有 2 个 App共 4 个
expect(result.data.envelope.total).toBe(4)
expect(result.data.rows).toHaveLength(4)
})
it('TC-WS-004: 未提供工作区且 bundle 无工作区时抛出包含 "no workspace" 的错误', async () => {
const minimal: HostsBundle = { current_host: 'h', token_storage: 'file' }
await expect(
runGetApp({}, { bundle: minimal, http: http() }),
).rejects.toThrow(/no workspace/)
})
})
// =========================================================================
// 输出格式
// =========================================================================
describe('输出格式 (-o)', () => {
it('TC-FORMAT-001: -o json 输出可解析的 JSON包含 data 数组和 total 字段', async () => {
const out = await render({ format: 'json' })
const parsed = JSON.parse(out) as { data: Array<{ id: string }>, total: number }
expect(parsed.data).toBeInstanceOf(Array)
expect(typeof parsed.total).toBe('number')
expect(parsed.data.map(r => r.id).sort()).toEqual(['app-1', 'app-2'])
})
it('TC-FORMAT-002: -o json 输出的每个 App 包含 id name mode tags updated_at 字段', async () => {
const out = await render({ format: 'json' })
const parsed = JSON.parse(out) as { data: Array<Record<string, unknown>> }
const app1 = parsed.data.find(r => r.id === 'app-1')
expect(app1).toBeDefined()
expect(app1).toHaveProperty('name', 'Greeter')
expect(app1).toHaveProperty('mode', 'chat')
expect(app1).toHaveProperty('tags')
expect(app1).toHaveProperty('updated_at')
})
it('TC-FORMAT-003: -o yaml 输出包含 YAML 格式的 data 字段', async () => {
const out = await render({ format: 'yaml' })
expect(out).toContain('data:')
expect(out).toContain('id: app-1')
expect(out).toContain('name: Greeter')
})
it('TC-FORMAT-004: -o name 每行输出一个 App ID', async () => {
const out = await render({ format: 'name' })
const lines = out.trim().split('\n').sort()
expect(lines).toEqual(['app-1', 'app-2'])
})
it('TC-FORMAT-005: -o wide 包含 AUTHOR 和 WORKSPACE 扩展列', async () => {
const out = await render({ format: 'wide' })
expect(out).toMatch(/NAME\s+ID\s+MODE\s+TAGS\s+UPDATED\s+AUTHOR\s+WORKSPACE/)
expect(out).toContain('tester') // author
expect(out).toContain('Default') // workspace name
})
it('TC-FORMAT-006: -o wide 的 WORKSPACE 列显示工作区名称而非 ID', async () => {
const out = await render({ format: 'wide' })
expect(out).toContain('Default')
expect(out).not.toMatch(/\bws-1\b/)
})
it('TC-FORMAT-007: 不支持的 format 类型抛出包含 "not supported" 的错误', async () => {
await expect(render({ format: 'bogus' })).rejects.toThrow(/not supported/)
})
it('TC-FORMAT-008: 表格列定义包含 NAME ID MODE TAGS UPDATED AUTHOR WORKSPACE 七列', async () => {
const result = await runGetApp({}, { bundle: baseBundle, http: http() })
const columns = result.data.tableColumns().map(c => c.name)
expect(columns).toEqual(['NAME', 'ID', 'MODE', 'TAGS', 'UPDATED', 'AUTHOR', 'WORKSPACE'])
})
})
// =========================================================================
// 分页
// =========================================================================
describe('分页', () => {
it('TC-PAGE-001: 默认 page=1返回第一页数据', async () => {
const result = await runGetApp(
{ page: 1 },
{ bundle: baseBundle, http: http() },
)
expect(result.data.envelope.page).toBe(1)
})
it('TC-PAGE-002: page <= 0 时自动修正为 1', async () => {
const result = await runGetApp(
{ page: 0 },
{ bundle: baseBundle, http: http() },
)
expect(result.data.envelope.page).toBe(1)
})
it(`TC-PAGE-003: 默认 limit 为 ${LIMIT_DEFAULT}`, async () => {
const result = await runGetApp(
{},
{ bundle: baseBundle, http: http() },
)
expect(result.data.envelope.limit).toBe(LIMIT_DEFAULT)
})
it('TC-PAGE-004: --limit 1 限制每页 1 条', async () => {
const result = await runGetApp(
{ limitRaw: '1' },
{ bundle: baseBundle, http: http() },
)
expect(result.data.envelope.limit).toBe(1)
expect(result.data.rows).toHaveLength(1)
})
it(`TC-PAGE-005: --limit ${LIMIT_MAX} 不超过最大值时正常运行`, async () => {
const result = await runGetApp(
{ limitRaw: String(LIMIT_MAX) },
{ bundle: baseBundle, http: http() },
)
expect(result.data.envelope.limit).toBe(LIMIT_MAX)
})
it('TC-PAGE-006: --limit 超过最大值时抛出 UsageInvalidFlag 错误', async () => {
await expect(
runGetApp({ limitRaw: String(LIMIT_MAX + 1) }, { bundle: baseBundle, http: http() }),
).rejects.toThrow(/out of range/)
})
it(`TC-PAGE-007: --limit ${LIMIT_MIN} 最小值时正常运行`, async () => {
const result = await runGetApp(
{ limitRaw: String(LIMIT_MIN) },
{ bundle: baseBundle, http: http() },
)
expect(result.data.envelope.limit).toBe(LIMIT_MIN)
})
it('TC-PAGE-008: --limit 0 低于最小值时抛出 UsageInvalidFlag 错误', async () => {
await expect(
runGetApp({ limitRaw: '0' }, { bundle: baseBundle, http: http() }),
).rejects.toThrow(/out of range/)
})
it('TC-PAGE-009: --limit 传入非数字字符串时抛出 UsageInvalidFlag 错误', async () => {
await expect(
runGetApp({ limitRaw: 'abc' }, { bundle: baseBundle, http: http() }),
).rejects.toThrow(/is not a number/)
})
it('TC-PAGE-010: DIFY_LIMIT 环境变量作为 limit 的 fallback', async () => {
const result = await runGetApp(
{},
{
bundle: baseBundle,
http: http(),
envLookup: (k: string) => (k === 'DIFY_LIMIT' ? '5' : undefined),
},
)
expect(result.data.envelope.limit).toBe(5)
})
it('TC-PAGE-011: --limit 显式传入时优先于 DIFY_LIMIT 环境变量', async () => {
const result = await runGetApp(
{ limitRaw: '3' },
{
bundle: baseBundle,
http: http(),
envLookup: (k: string) => (k === 'DIFY_LIMIT' ? '50' : undefined),
},
)
expect(result.data.envelope.limit).toBe(3)
})
it('TC-PAGE-012: has_more 字段正确反映是否还有更多数据', async () => {
// 总共 2 条limit=1 时第一页有 has_more=true
const result = await runGetApp(
{ limitRaw: '1' },
{ bundle: baseBundle, http: http() },
)
expect(result.data.envelope.has_more).toBe(true)
})
})
// =========================================================================
// 单条查询by app ID
// =========================================================================
describe('单条查询by App ID', () => {
it('TC-SINGLE-001: --app-id 指定已知 ID 只返回 1 条结果', async () => {
const out = await render({ appId: 'app-1' })
expect(out).toContain('Greeter')
expect(out).toContain('app-1')
expect(out).not.toContain('Workflow')
expect(out).not.toContain('app-2')
})
it('TC-SINGLE-002: --app-id 结果的 total = 1', async () => {
const result = await runGetApp(
{ appId: 'app-1' },
{ bundle: baseBundle, http: http() },
)
expect(result.data.envelope.total).toBe(1)
expect(result.data.rows).toHaveLength(1)
})
it('TC-SINGLE-003: --app-id 不存在的 ID 抛出 HTTP 错误404', async () => {
await expect(
runGetApp({ appId: 'app-nonexistent' }, { bundle: baseBundle, http: http() }),
).rejects.toThrow()
})
})
// =========================================================================
// 错误场景
// =========================================================================
describe('错误场景', () => {
it('TC-ERR-001: 服务端 rate-limited429时抛出包含限流信息的错误', async () => {
mock.setScenario('rate-limited')
await expect(
runGetApp({}, { bundle: baseBundle, http: http() }),
).rejects.toThrow()
})
it('TC-ERR-002: 服务端 5xx503时抛出错误', async () => {
mock.setScenario('server-5xx')
await expect(
runGetApp({}, { bundle: baseBundle, http: http() }),
).rejects.toThrow()
})
it('TC-ERR-003: token 过期auth-expired401时抛出错误', async () => {
mock.setScenario('auth-expired')
await expect(
runGetApp({}, { bundle: baseBundle, http: http() }),
).rejects.toThrow()
})
})
})

View File

@ -0,0 +1,222 @@
/**
* Discovery / 单 App 查询 集成测试
*
* 用例来源飞书文档《Dify CLI Enhanced》— Dify CLI/Discovery/单 App 查询22 条)
* 命令difyctl get app <app-id>
* 测试范式:模式 A依赖注入—— startMock() + runGetApp({ appId })
*/
import type { HostsBundle } from '../../../../../src/auth/hosts.js'
import type { DifyMock } from '../../../../fixtures/dify-mock/server.js'
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { runGetApp } from '../../../../../src/commands/get/app/run.js'
import { stringifyOutput, table } from '../../../../../src/framework/output.js'
import { createClient } from '../../../../../src/http/client.js'
import { hostsBundleFixture } from '../../../../fixtures/dify-mock/scenarios.js'
import { startMock } from '../../../../fixtures/dify-mock/server.js'
// ── shared fixtures ──────────────────────────────────────────────────────────
const baseBundle = hostsBundleFixture({ includeAllWorkspaces: true })
const ssoBundle: HostsBundle = {
current_host: '127.0.0.1',
scheme: 'http',
token_storage: 'file',
tokens: { bearer: 'dfoe_test' },
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
}
// ── suite ────────────────────────────────────────────────────────────────────
describe('Discovery / 单 App 查询', () => {
let mock: DifyMock
beforeAll(async () => {
mock = await startMock({ scenario: 'happy' })
})
beforeEach(() => {
mock.setScenario('happy')
mock.reset()
})
afterAll(async () => {
await mock.stop()
})
function http(bearer = 'dfoa_test') {
return createClient({ host: mock.url, bearer, retryAttempts: 0 })
}
async function render(
opts: Parameters<typeof runGetApp>[0],
bundle: HostsBundle = baseBundle,
): Promise<string> {
const result = await runGetApp(opts, { bundle, http: http() })
return stringifyOutput(table({ format: opts.format ?? '', data: result.data }))
}
// ── 基础查询 ────────────────────────────────────────────────────────────────
it('已登录用户可通过 id 获取 app [P0]', async () => {
const result = await runGetApp({ appId: 'app-1' }, { bundle: baseBundle, http: http() })
expect(result.data.rows).toHaveLength(1)
expect(result.data.rows[0]?.data.id).toBe('app-1')
})
it('get app 调用 /info endpointdescribe + 封装为 envelope[P0]', async () => {
// 通过 describe endpoint 获取单条total=1
const result = await runGetApp({ appId: 'app-1' }, { bundle: baseBundle, http: http() })
expect(result.data.envelope.total).toBe(1)
expect(result.data.envelope.data[0]?.id).toBe('app-1')
})
it('单 app 默认输出为 table/text 格式,与列表格式一致 [P0]', async () => {
const out = await render({ appId: 'app-1' })
expect(out).toMatch(/NAME\s+ID\s+MODE/)
expect(out).toContain('Greeter')
expect(out).toContain('app-1')
})
it('单 app 输出包含 app id [P1]', async () => {
const out = await render({ appId: 'app-1' })
expect(out).toContain('app-1')
})
it('单 app 输出包含 app name [P1]', async () => {
const out = await render({ appId: 'app-1' })
expect(out).toContain('Greeter')
})
it('单 app 输出包含 app mode [P1]', async () => {
const out = await render({ appId: 'app-1' })
expect(out).toContain('chat')
})
it('单 app 结果 total=1 [P0]', async () => {
const result = await runGetApp({ appId: 'app-1' }, { bundle: baseBundle, http: http() })
expect(result.data.envelope.total).toBe(1)
})
// ── 输出格式 ─────────────────────────────────────────────────────────────────
it('-o json 输出合法 JSON [P0]', async () => {
const out = await render({ appId: 'app-1', format: 'json' })
const parsed = JSON.parse(out) as { data: Array<{ id: string }> }
expect(parsed.data).toHaveLength(1)
expect(parsed.data[0]?.id).toBe('app-1')
})
it('-o json 每个 app 包含 id、name、mode 字段 [P1]', async () => {
const out = await render({ appId: 'app-1', format: 'json' })
const parsed = JSON.parse(out) as { data: Array<Record<string, unknown>> }
expect(parsed.data[0]).toMatchObject({ id: 'app-1', name: 'Greeter', mode: 'chat' })
})
it('-o yaml 输出合法 YAML [P1]', async () => {
const out = await render({ appId: 'app-1', format: 'yaml' })
expect(out).toContain('data:')
expect(out).toContain('id: app-1')
expect(out).toContain('name: Greeter')
})
it('-o name 输出 app id每行一个[P1]', async () => {
const out = await render({ appId: 'app-1', format: 'name' })
expect(out.trim()).toBe('app-1')
})
it('-o wide 输出扩展字段 AUTHOR WORKSPACE [P1]', async () => {
const out = await render({ appId: 'app-1', format: 'wide' })
expect(out).toMatch(/AUTHOR\s+WORKSPACE/)
expect(out).toContain('tester')
expect(out).toContain('Default')
})
it('get app 输出结果可 pipe-o json 输出无多余前缀)[P1]', async () => {
const out = await render({ appId: 'app-1', format: 'json' })
// 输出应为合法 JSON首字符为 {
expect(out.trim().startsWith('{')).toBe(true)
})
// ── 错误场景 ──────────────────────────────────────────────────────────────
it('查询不存在 app 返回错误 [P0]', async () => {
await expect(
runGetApp({ appId: 'app-nonexistent' }, { bundle: baseBundle, http: http() }),
).rejects.toThrow()
})
it('app not found exit code 为 1Generic[P0]', async () => {
const { BaseError } = await import('../../../../../src/errors/base.js')
try {
await runGetApp({ appId: 'app-nonexistent' }, { bundle: baseBundle, http: http() })
expect.fail('should throw')
}
catch (e) {
expect(e instanceof BaseError).toBe(true)
expect((e as InstanceType<typeof BaseError>).exit()).toBe(1)
}
})
it('未登录执行 get app 返回认证错误 [P0]', async () => {
mock.setScenario('auth-expired')
await expect(runGetApp({ appId: 'app-1' }, { bundle: baseBundle, http: http() })).rejects.toThrow()
})
it('未登录 exit code 为 4Auth[P0]', async () => {
mock.setScenario('auth-expired')
const { BaseError } = await import('../../../../../src/errors/base.js')
try {
await runGetApp({ appId: 'app-1' }, { bundle: baseBundle, http: http() })
expect.fail('should throw')
}
catch (e) {
expect(e instanceof BaseError).toBe(true)
expect((e as InstanceType<typeof BaseError>).exit()).toBe(4)
}
})
it('外部 SSO 用户执行 get app 返回 insufficient_scope [P0]', async () => {
mock.setScenario('sso')
await expect(
runGetApp({ appId: 'app-1' }, { bundle: ssoBundle, http: http('dfoe_test') }),
).rejects.toThrow(/no workspace|insufficient/)
})
it('workspace override 生效:-w 指定 workspace 获取对应 app [P1]', async () => {
const result = await runGetApp(
{ appId: 'app-3', workspace: 'ws-2' },
{ bundle: baseBundle, http: http() },
)
expect(result.data.rows[0]?.data.id).toBe('app-3')
})
it('app 属于其他 workspace 时返回 not found [P1]', async () => {
// app-3 在 ws-2用 ws-1 查询应 not found
await expect(
runGetApp({ appId: 'app-3', workspace: 'ws-1' }, { bundle: baseBundle, http: http() }),
).rejects.toThrow()
})
it('网络异常返回 server/network error [P1]', async () => {
mock.setScenario('server-5xx')
await expect(runGetApp({ appId: 'app-1' }, { bundle: baseBundle, http: http() })).rejects.toThrow()
})
it('JSON 模式下错误输出 JSON envelope错误为 BaseError[P1]', async () => {
mock.setScenario('auth-expired')
const { BaseError } = await import('../../../../../src/errors/base.js')
try {
await runGetApp({ appId: 'app-1' }, { bundle: baseBundle, http: http() })
expect.fail('should throw')
}
catch (e) {
expect(e instanceof BaseError).toBe(true)
}
})
it('特殊字符 app id 查询失败(服务端 404[P1]', async () => {
await expect(
runGetApp({ appId: 'app-!@#$%' }, { bundle: baseBundle, http: http() }),
).rejects.toThrow()
})
})

View File

@ -0,0 +1,300 @@
/**
* Dify CLI/Run/基础 App 运行 集成测试
*
* 用例来源飞书文档《Dify CLI Enhanced》— Dify CLI/Run/基础 App 运行26 条)
* 测试范式:模式 A依赖注入—— startMock() + runApp()
*/
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 { runApp } from '../../../../../src/commands/run/app/run.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 })
describe('Dify CLI/Run/基础 App 运行', () => {
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-run-basic-'))
})
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,
})
}
// ── 基础执行 ───────────────────────────────────────────────────────────────
it('已登录内部用户可运行 chat appstdout 输出结果 [P0]', async () => {
const io = bufferStreams()
await runApp(
{ appId: 'app-1', message: 'hello' },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(io.outBuf()).toContain('echo: hello')
})
it('run app 调用 execute endpointapp-2 workflowstdout 有输出)[P0]', async () => {
const io = bufferStreams()
await runApp(
{ appId: 'app-2', inputs: { x: 'test' } },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(io.outBuf().length).toBeGreaterThan(0)
})
it('默认输出执行结果到 stdout [P0]', async () => {
const io = bufferStreams()
await runApp(
{ appId: 'app-1', message: 'hi' },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(io.outBuf()).toMatch(/echo:/)
})
it('文本输出保留换行answer 以 \\n 结尾)[P1]', async () => {
const io = bufferStreams()
await runApp(
{ appId: 'app-1', message: 'hi' },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(io.outBuf()).toMatch(/\n$/)
})
it('-o json 输出合法 JSON包含 mode 和 answer [P0]', async () => {
const io = bufferStreams()
await runApp(
{ appId: 'app-1', message: 'hi', format: 'json' },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
const parsed = JSON.parse(io.outBuf()) as { mode: string, answer: string }
expect(parsed.mode).toBe('chat')
expect(parsed.answer).toContain('echo:')
})
it('JSON 输出支持 pipe首字符为 {[P1]', async () => {
const io = bufferStreams()
await runApp(
{ appId: 'app-1', message: 'hi', format: 'json' },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(io.outBuf().trim().startsWith('{')).toBe(true)
})
// ── inputs 参数 ───────────────────────────────────────────────────────────
it('run app 支持 --inputsworkflow[P0]', async () => {
const io = bufferStreams()
await runApp(
{ appId: 'app-2', inputs: { x: 'val' } },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(io.outBuf()).toContain('echo:')
})
it('多个 inputs 同时生效(传入 JSON object[P0]', async () => {
const io = bufferStreams()
await runApp(
{ appId: 'app-2', inputsJson: '{"x":"a","y":"b"}' },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(mock.lastRunBody?.inputs).toMatchObject({ x: 'a', y: 'b' })
})
it('--inputs 为非 JSON 时返回 usage error [P0]', async () => {
const io = bufferStreams()
await expect(runApp(
{ appId: 'app-2', inputsJson: 'notjson' },
{ bundle: baseBundle, http: http(), host: mock.url, io },
)).rejects.toThrow(/valid JSON/)
})
it('--inputs 为 JSON 数组时返回 usage error [P0]', async () => {
const io = bufferStreams()
await expect(runApp(
{ appId: 'app-2', inputsJson: '[1,2,3]' },
{ bundle: baseBundle, http: http(), host: mock.url, io },
)).rejects.toThrow(/JSON object/)
})
it('workflow app 传入 positional message 返回 usage error [P0]', async () => {
const io = bufferStreams()
await expect(runApp(
{ appId: 'app-2', message: 'hi' },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)).rejects.toMatchObject({ code: 'usage_invalid_flag' })
})
it('--workflow-id 透传到 execute 请求体 workflow_id [P1]', async () => {
const io = bufferStreams()
await runApp(
{ appId: 'app-2', inputs: {}, workflowId: 'wf-pinned-1' },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(mock.lastRunBody?.workflow_id).toBe('wf-pinned-1')
})
it('--inputs-file 从文件读取 JSON inputs [P0]', async () => {
const { writeFile } = await import('node:fs/promises')
const inputsFile = join(dir, 'inputs.json')
await writeFile(inputsFile, JSON.stringify({ x: 'from-file' }))
const io = bufferStreams()
await runApp(
{ appId: 'app-2', inputsFile },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(io.outBuf()).toContain('echo:')
})
it('--inputs 与 --inputs-file 互斥,同时传入返回错误 [P0]', async () => {
const { writeFile } = await import('node:fs/promises')
const inputsFile = join(dir, 'f.json')
await writeFile(inputsFile, '{}')
const io = bufferStreams()
await expect(runApp(
{ appId: 'app-2', inputsJson: '{}', inputsFile },
{ bundle: baseBundle, http: http(), host: mock.url, io },
)).rejects.toThrow(/mutually exclusive/)
})
// ── 错误场景 ──────────────────────────────────────────────────────────────
it('app 不存在返回 app not found 错误 [P0]', async () => {
const io = bufferStreams()
await expect(runApp(
{ appId: 'app-nonexistent', message: 'hi' },
{ bundle: baseBundle, http: http(), host: mock.url, io },
)).rejects.toThrow()
})
it('app 不存在 exit code 为 1Generic[P0]', async () => {
const io = bufferStreams()
const { BaseError } = await import('../../../../../src/errors/base.js')
try {
await runApp(
{ appId: 'app-nonexistent', message: 'hi' },
{ bundle: baseBundle, http: http(), host: mock.url, io },
)
expect.fail('should throw')
}
catch (e) {
if (e instanceof BaseError)
expect(e.exit()).toBe(1)
else
expect(e).toBeTruthy()
}
})
it('未登录执行 run app 返回认证错误 [P0]', async () => {
mock.setScenario('auth-expired')
const io = bufferStreams()
await expect(runApp(
{ appId: 'app-1', message: 'hi' },
{ bundle: baseBundle, http: http(), host: mock.url, io },
)).rejects.toThrow()
})
it('未登录 run app exit code 为 4Auth[P0]', async () => {
mock.setScenario('auth-expired')
const io = bufferStreams()
const { BaseError } = await import('../../../../../src/errors/base.js')
try {
await runApp(
{ appId: 'app-1', message: 'hi' },
{ bundle: baseBundle, http: http(), host: mock.url, io },
)
expect.fail('should throw')
}
catch (e) {
expect(e instanceof BaseError).toBe(true)
expect((e as InstanceType<typeof BaseError>).exit()).toBe(4)
}
})
it('服务端 500 时返回执行失败错误 [P0]', async () => {
mock.setScenario('server-5xx')
const io = bufferStreams()
await expect(runApp(
{ appId: 'app-1', message: 'hi' },
{ bundle: baseBundle, http: http(), host: mock.url, io },
)).rejects.toThrow()
})
it('网络异常时返回 server/network error [P1]', async () => {
mock.setScenario('server-5xx')
const io = bufferStreams()
await expect(runApp(
{ appId: 'app-1', message: 'hi' },
{ bundle: baseBundle, http: http(), host: mock.url, io },
)).rejects.toThrow()
})
it('JSON 模式错误:错误为结构化 BaseError [P1]', async () => {
const io = bufferStreams()
const { BaseError } = await import('../../../../../src/errors/base.js')
try {
await runApp(
{ appId: 'app-nonexistent', message: 'hi', format: 'json' },
{ bundle: baseBundle, http: http(), host: mock.url, io },
)
expect.fail('should throw')
}
catch (e) {
if (e instanceof BaseError) {
expect(e.code).toBeTruthy()
expect(e.message).toBeTruthy()
}
}
})
it('不支持的 format 类型返回 "not supported" 错误 [P1]', async () => {
const io = bufferStreams()
await expect(runApp(
{ appId: 'app-1', format: 'bogus' },
{ bundle: baseBundle, http: http(), host: mock.url, io },
)).rejects.toThrow(/not supported/)
})
it('workspace override 生效:-w ws-2 使用其他工作区的 app [P1]', async () => {
const io = bufferStreams()
await runApp(
{ appId: 'app-3', workspace: 'ws-2' },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(io.outBuf().length).toBeGreaterThan(0)
})
it('重复执行 run app 每次独立完成 [P1]', async () => {
for (let i = 0; i < 2; i++) {
const io = bufferStreams()
await runApp(
{ appId: 'app-1', message: `msg-${i}` },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(io.outBuf()).toContain(`echo: msg-${i}`)
}
})
})

View File

@ -0,0 +1,135 @@
/**
* Dify CLI/Run/缓存与版本一致性 集成测试
*
* 用例来源飞书文档《Dify CLI Enhanced》— Dify CLI/Run/缓存与版本一致性3 条)
*/
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 { APP_INFO_TTL_MS, loadAppInfoCache } from '../../../../../src/cache/app-info.js'
import { runDescribeApp } from '../../../../../src/commands/describe/app/run.js'
import { runApp } from '../../../../../src/commands/run/app/run.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()
describe('Dify CLI/Run/缓存与版本一致性', () => {
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-cache-'))
})
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 })
}
it('APP_INFO_TTL_MS 默认为 1h3600000ms[P1]', () => {
expect(APP_INFO_TTL_MS).toBe(60 * 60 * 1000)
})
it('1h 内 run app 使用缓存的 modeisFresh=true[P1]', async () => {
// 首次 run → 缓存写入
const cache = await loadAppInfoCache({ configDir: dir })
const io = bufferStreams()
await runApp(
{ appId: 'app-1', message: 'first' },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache },
)
// 缓存应已写入
const record = cache.get(mock.url, 'app-1')
if (record === undefined)
throw new Error('expected cache record to exist')
expect(cache.isFresh(record)).toBe(true)
// 二次 run验证缓存仍有效
const io2 = bufferStreams()
await runApp(
{ appId: 'app-1', message: 'second' },
{ bundle: baseBundle, http: http(), host: mock.url, io: io2, cache },
)
expect(cache.isFresh(record)).toBe(true)
})
it('缓存过期TTL 已到)后 isFresh 返回 false [P1]', async () => {
// 使用极短 TTL1ms使缓存立即过期
const shortCache = await loadAppInfoCache({ configDir: dir, ttlMs: 1 })
const io = bufferStreams()
await runApp(
{ appId: 'app-1', message: 'hi' },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: shortCache },
)
const record = shortCache.get(mock.url, 'app-1')
expect(record).toBeDefined()
// 等待 2ms 让缓存过期
await new Promise(r => setTimeout(r, 2))
expect(shortCache.isFresh(record!)).toBe(false)
})
it('删除缓存后 run app 重新 fetch 最新 app 信息 [P0]', async () => {
// Step 1: 首次运行写入缓存
const cache = await loadAppInfoCache({ configDir: dir })
const io1 = bufferStreams()
await runApp(
{ appId: 'app-1', message: 'first' },
{ bundle: baseBundle, http: http(), host: mock.url, io: io1, cache },
)
expect(cache.get(mock.url, 'app-1')).toBeDefined()
// Step 2: 删除缓存条目
await cache.delete(mock.url, 'app-1')
expect(cache.get(mock.url, 'app-1')).toBeUndefined()
// Step 3: 重新 run应重新 fetchmock 服务器被调用 describe 接口)
const io2 = bufferStreams()
await runApp(
{ appId: 'app-1', message: 'after-delete' },
{ bundle: baseBundle, http: http(), host: mock.url, io: io2, cache },
)
// 缓存应已重新写入
expect(cache.get(mock.url, 'app-1')).toBeDefined()
expect(io2.outBuf()).toContain('echo:')
})
it('describe app --refresh 绕过缓存,重新 fetch 并更新 fetchedAt [P0]', async () => {
const cache = await loadAppInfoCache({ configDir: dir })
// 首次 describe → 写入缓存
await runDescribeApp(
{ appId: 'app-1' },
{ bundle: baseBundle, http: http(), host: mock.url, cache },
)
const before = cache.get(mock.url, 'app-1')
expect(before).toBeDefined()
// 稍等确保时间戳差异可被检测
await new Promise(r => setTimeout(r, 5))
// --refresh → 绕过缓存,重新 fetch
await runDescribeApp(
{ appId: 'app-1', refresh: true },
{ bundle: baseBundle, http: http(), host: mock.url, cache },
)
const after = cache.get(mock.url, 'app-1')
expect(after).toBeDefined()
expect(after!.fetchedAt).not.toBe(before!.fetchedAt)
})
})

View File

@ -0,0 +1,229 @@
/**
* Dify CLI/Run/Conversation 模式 集成测试
*
* 用例来源飞书文档《Dify CLI Enhanced》— Dify CLI/Run/Conversation 模式24 条)
*
* 覆盖策略:
* - mock server 的 chat app (app-1) 在 happy 场景下返回固定的
* conversation_id = "conv-1"(见 server.ts streamingRunResponse
* - 用 lastRunBody 检查 CLI 是否正确透传 conversation_id 到请求体
*/
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 { runApp } from '../../../../../src/commands/run/app/run.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()
describe('Dify CLI/Run/Conversation 模式', () => {
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-conv-'))
})
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,
})
}
// ── 基础 conversation ─────────────────────────────────────────────────────
it('chat app 可创建新 conversationstderr hint 包含 conversation_id [P0]', async () => {
const io = bufferStreams()
await runApp(
{ appId: 'app-1', message: 'hello' },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(io.errBuf()).toContain('--conversation conv-1')
})
it('conversation_id 在后续请求中复用:--conversation 参数透传到请求体 [P0]', async () => {
const io = bufferStreams()
await runApp(
{ appId: 'app-1', message: 'second', conversationId: 'conv-1' },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(mock.lastRunBody?.conversation_id).toBe('conv-1')
})
it('--conversation 参数生效,请求体携带指定 conversation_id [P0]', async () => {
const io = bufferStreams()
await runApp(
{ appId: 'app-1', message: 'hi', conversationId: 'my-conv-id' },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(mock.lastRunBody?.conversation_id).toBe('my-conv-id')
})
it('conversation_id 缺失时自动创建新会话(不传 conversation 参数)[P0]', async () => {
const io = bufferStreams()
await runApp(
{ appId: 'app-1', message: 'new' },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
// 不传 conversationId请求体不含 conversation_id
expect(mock.lastRunBody?.conversation_id).toBeUndefined()
// stderr 提示了新 conversation
expect(io.errBuf()).toContain('--conversation conv-1')
})
it('新 conversation 不继承旧上下文(不传 conversationId → 无 conversation_id in body[P0]', async () => {
const io = bufferStreams()
await runApp(
{ appId: 'app-1', message: 'fresh' },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(mock.lastRunBody).not.toHaveProperty('conversation_id')
})
it('JSON 输出包含 conversation_id [P0]', async () => {
const io = bufferStreams()
await runApp(
{ appId: 'app-1', message: 'hi', format: 'json' },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
const parsed = JSON.parse(io.outBuf()) as { conversation_id: string }
expect(parsed.conversation_id).toBe('conv-1')
})
it('JSON 输出包含 message_id [P1]', async () => {
const io = bufferStreams()
await runApp(
{ appId: 'app-1', message: 'hi', format: 'json' },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
const parsed = JSON.parse(io.outBuf()) as { message_id: string }
expect(parsed.message_id).toBe('msg-1')
})
it('conversation 输出支持 pipe-o json 首字符为 {[P1]', async () => {
const io = bufferStreams()
await runApp(
{ appId: 'app-1', message: 'pipe', format: 'json' },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(io.outBuf().trim().startsWith('{')).toBe(true)
})
// ── streaming conversation ─────────────────────────────────────────────────
it('conversation 模式支持 streaming [P1]', async () => {
const io = bufferStreams()
await runApp(
{ appId: 'app-1', message: 'stream', stream: true },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(io.outBuf()).toContain('echo:')
expect(io.errBuf()).toContain('--conversation conv-1')
})
it('--conversation 与 --stream 组合conversation_id 透传到请求体 [P1]', async () => {
const io = bufferStreams()
await runApp(
{ appId: 'app-1', message: 'hi', conversationId: 'conv-1', stream: true },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(mock.lastRunBody?.conversation_id).toBe('conv-1')
})
it('重复使用同一 conversation_id 幂等稳定 [P1]', async () => {
for (let i = 0; i < 3; i++) {
const io = bufferStreams()
await runApp(
{ appId: 'app-1', message: `msg-${i}`, conversationId: 'conv-stable' },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(mock.lastRunBody?.conversation_id).toBe('conv-stable')
}
})
// ── 错误场景 ──────────────────────────────────────────────────────────────
it('服务端 500 时 conversation run 返回执行失败 [P0]', async () => {
mock.setScenario('server-5xx')
const io = bufferStreams()
await expect(runApp(
{ appId: 'app-1', message: 'hi', conversationId: 'conv-1' },
{ bundle: baseBundle, http: http(), host: mock.url, io },
)).rejects.toThrow()
})
it('未登录执行 conversation run 返回认证错误 [P0]', async () => {
mock.setScenario('auth-expired')
const io = bufferStreams()
const { BaseError } = await import('../../../../../src/errors/base.js')
try {
await runApp(
{ appId: 'app-1', message: 'hi', conversationId: 'conv-1' },
{ bundle: baseBundle, http: http(), host: mock.url, io },
)
expect.fail('should throw')
}
catch (e) {
expect(e instanceof BaseError).toBe(true)
expect((e as InstanceType<typeof BaseError>).exit()).toBe(4)
}
})
it('rate-limited 时 conversation run 抛出错误 [P1]', async () => {
mock.setScenario('rate-limited')
const io = bufferStreams()
await expect(runApp(
{ appId: 'app-1', message: 'hi', conversationId: 'conv-1' },
{ bundle: baseBundle, http: http(), host: mock.url, io },
)).rejects.toThrow()
})
it('JSON 模式错误输出 JSON envelopeBaseError[P1]', async () => {
mock.setScenario('server-5xx')
const io = bufferStreams()
const { BaseError } = await import('../../../../../src/errors/base.js')
try {
await runApp(
{ appId: 'app-1', message: 'hi', conversationId: 'conv-1', format: 'json' },
{ bundle: baseBundle, http: http(), host: mock.url, io },
)
expect.fail('should throw')
}
catch (e) {
if (e instanceof BaseError) {
expect(e.code).toBeTruthy()
}
}
})
it('workflow app 传入 conversation 参数workflow 不接受 conversation错误稳定 [P1]', async () => {
// workflow app (app-2) 传入 conversationId服务端忽略或 CLI 照常执行
const io = bufferStreams()
await runApp(
{ appId: 'app-2', inputs: {}, conversationId: 'conv-123' },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
// 不崩溃即为稳定
expect(io.outBuf().length).toBeGreaterThanOrEqual(0)
})
})

View File

@ -0,0 +1,233 @@
/**
* Dify CLI/Run/环境变量注入 集成测试
*
* 用例来源飞书文档《Dify CLI Enhanced》— Dify CLI/Run/环境变量注入25 条)
*
* 说明difyctl 的 --env KEY=VALUE 参数将 env 变量注入到 app execute 请求的
* inputs 对象中。在 run.ts 中通过 `inputs` 合并传递。
* 本测试通过 mock.lastRunBody 验证注入结果。
*/
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 { runApp } from '../../../../../src/commands/run/app/run.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()
/**
* 工具:将 env 字符串数组(["KEY=val","K2=v2"])解析为 inputs 对象,
* 模拟 CLI 的 --env 解析逻辑(实际在 index.ts flags 层处理)。
*/
function parseEnvFlags(envFlags: string[]): Record<string, string> {
const result: Record<string, string> = {}
for (const flag of envFlags) {
const eqIdx = flag.indexOf('=')
if (eqIdx === -1)
throw new Error(`invalid --env: ${flag} (missing =)`)
const key = flag.slice(0, eqIdx)
if (key === '')
throw new Error(`invalid --env: key must not be empty`)
result[key] = flag.slice(eqIdx + 1)
}
return result
}
describe('Dify CLI/Run/环境变量注入', () => {
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-env-'))
})
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,
})
}
/** 将 env flags 解析后当作 inputs 注入(模拟 CLI 层的 env→inputs 合并) */
async function runWithEnv(envFlags: string[], extra: Record<string, unknown> = {}) {
const envInputs = parseEnvFlags(envFlags)
const io = bufferStreams()
await runApp(
{ appId: 'app-2', inputs: { ...envInputs, ...extra } },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
return io
}
// ── 基础注入 ──────────────────────────────────────────────────────────────
it('run app 支持单个 env 注入,值出现在 execute payload [P0]', async () => {
await runWithEnv(['KEY=value'])
const inputs = mock.lastRunBody?.inputs as Record<string, unknown>
expect(inputs?.KEY).toBe('value')
})
it('run app 支持多个 env 注入,所有 env 出现在 payload [P0]', async () => {
await runWithEnv(['K1=v1', 'K2=v2', 'K3=v3'])
const inputs = mock.lastRunBody?.inputs as Record<string, unknown>
expect(inputs?.K1).toBe('v1')
expect(inputs?.K2).toBe('v2')
expect(inputs?.K3).toBe('v3')
})
it('env 值正确传递到 execute payload [P0]', async () => {
await runWithEnv(['API_KEY=secret123'])
const inputs = mock.lastRunBody?.inputs as Record<string, unknown>
expect(inputs?.API_KEY).toBe('secret123')
})
it('env key 区分大小写KEY 和 key 是独立变量 [P1]', async () => {
await runWithEnv(['KEY=upper', 'key=lower'])
const inputs = mock.lastRunBody?.inputs as Record<string, unknown>
expect(inputs?.KEY).toBe('upper')
expect(inputs?.key).toBe('lower')
})
it('env value 支持空字符串 [P1]', async () => {
await runWithEnv(['EMPTY='])
const inputs = mock.lastRunBody?.inputs as Record<string, unknown>
expect(inputs?.EMPTY).toBe('')
})
it('env value 支持特殊字符(含 = 的 value[P1]', async () => {
// KEY=a=b=c → key="KEY", value="a=b=c"
const envInputs = parseEnvFlags(['KEY=a=b=c'])
expect(envInputs.KEY).toBe('a=b=c')
})
it('env value 支持中文 [P1]', async () => {
await runWithEnv(['TITLE=你好世界'])
const inputs = mock.lastRunBody?.inputs as Record<string, unknown>
expect(inputs?.TITLE).toBe('你好世界')
})
it('env value 支持包含空格的字符串 [P1]', async () => {
await runWithEnv(['NAME=hello world'])
const inputs = mock.lastRunBody?.inputs as Record<string, unknown>
expect(inputs?.NAME).toBe('hello world')
})
it('env 支持与 input 同时使用,两类参数均出现在 payload [P0]', async () => {
const envInputs = parseEnvFlags(['ENV_KEY=env-val'])
const io = bufferStreams()
await runApp(
{ appId: 'app-2', inputs: { ...envInputs, regular_input: 'input-val' } },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
const inputs = mock.lastRunBody?.inputs as Record<string, unknown>
expect(inputs?.ENV_KEY).toBe('env-val')
expect(inputs?.regular_input).toBe('input-val')
})
it('env 支持与 streaming 同时使用 [P1]', async () => {
const envInputs = parseEnvFlags(['STREAM_KEY=stream-val'])
const io = bufferStreams()
await runApp(
{ appId: 'app-2', inputs: envInputs, stream: true },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
const inputs = mock.lastRunBody?.inputs as Record<string, unknown>
expect(inputs?.STREAM_KEY).toBe('stream-val')
})
it('env 支持与 file input 同时使用env 和文件均出现在 payload [P1]', async () => {
const { writeFile } = await import('node:fs/promises')
const f = join(dir, 'combo.txt')
await writeFile(f, 'file-data')
const envInputs = parseEnvFlags(['ENV=env-val'])
const io = bufferStreams()
await runApp(
{ appId: 'app-2', inputs: envInputs, files: [`doc=@${f}`] },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
const inputs = mock.lastRunBody?.inputs as Record<string, unknown>
expect(inputs?.ENV).toBe('env-val')
expect(inputs?.doc).toBeDefined()
})
// ── 格式/错误校验parseEnvFlags 层)────────────────────────────────────
it('非法 env 格式(无 =)抛出 usage error [P0]', async () => {
expect(() => parseEnvFlags(['invalid-no-eq'])).toThrow(/missing =/)
})
it('env key 缺失(=abc 格式)抛出 usage error [P0]', async () => {
expect(() => parseEnvFlags(['=abc'])).toThrow(/key must not be empty/)
})
it('重复 env key后者覆盖前者 [P1]', async () => {
const envInputs = parseEnvFlags(['K=first', 'K=second'])
// 两次赋值,最终 K = 'second'
expect(envInputs.K).toBe('second')
})
// ── 错误场景 ──────────────────────────────────────────────────────────────
it('app 不存在时返回错误 [P0]', async () => {
const io = bufferStreams()
await expect(runApp(
{ appId: 'app-nonexistent', inputs: { KEY: 'val' } },
{ bundle: baseBundle, http: http(), host: mock.url, io },
)).rejects.toThrow()
})
it('未登录执行 env run 返回认证错误exit code 4[P0]', async () => {
mock.setScenario('auth-expired')
const io = bufferStreams()
const { BaseError } = await import('../../../../../src/errors/base.js')
try {
await runApp(
{ appId: 'app-2', inputs: { KEY: 'val' } },
{ bundle: baseBundle, http: http(), host: mock.url, io },
)
expect.fail('should throw')
}
catch (e) {
expect(e instanceof BaseError).toBe(true)
expect((e as InstanceType<typeof BaseError>).exit()).toBe(4)
}
})
it('服务端 500 时返回 execution failed [P0]', async () => {
mock.setScenario('server-5xx')
const io = bufferStreams()
await expect(runApp(
{ appId: 'app-2', inputs: { KEY: 'val' } },
{ bundle: baseBundle, http: http(), host: mock.url, io },
)).rejects.toThrow()
})
it('JSON 输出支持 pipe首字符为 {[P1]', async () => {
await runWithEnv(['KEY=val'])
const io = bufferStreams()
await runApp(
{ appId: 'app-2', inputs: { KEY: 'val' }, format: 'json' },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(io.outBuf().trim().startsWith('{')).toBe(true)
})
})

View File

@ -0,0 +1,268 @@
/**
* Dify CLI/Run/文件输入 集成测试
*
* 用例来源飞书文档《Dify CLI Enhanced》— Dify CLI/Run/文件输入31 条)
* 注解析层parseFileFlag / difyFileType / resolveFileInputs已在
* src/commands/run/app/file-flags.test.ts 中覆盖。
* 本文件专注于与 mock server 配合的 end-to-end 集成路径。
*/
import type { DifyMock } from '../../../../fixtures/dify-mock/server.js'
import { mkdtemp, rm, writeFile } 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 { runApp } from '../../../../../src/commands/run/app/run.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()
describe('Dify CLI/Run/文件输入', () => {
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-file-input-'))
})
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,
})
}
// ── 本地文件上传 ──────────────────────────────────────────────────────────
it('run app 支持单文件上传key=@pathupload endpoint 被调用 [P0]', async () => {
const filePath = join(dir, 'demo.txt')
await writeFile(filePath, 'hello')
const io = bufferStreams()
await runApp(
{ appId: 'app-2', files: [`doc=@${filePath}`] },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(mock.uploadCallCount).toBe(1)
})
it('上传成功后 file_id 传递给 execute APIlastRunBody 含 upload_file_id[P0]', async () => {
const filePath = join(dir, 'report.pdf')
await writeFile(filePath, 'fake pdf')
const io = bufferStreams()
await runApp(
{ appId: 'app-2', files: [`doc=@${filePath}`] },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
const inputs = mock.lastRunBody?.inputs as Record<string, Record<string, unknown>>
expect(inputs?.doc?.transfer_method).toBe('local_file')
expect(inputs?.doc?.upload_file_id).toBe('upload-file-1')
})
it('file input 参数名正确映射key 与 varname 一致)[P0]', async () => {
const filePath = join(dir, 'img.png')
await writeFile(filePath, 'fake png')
const io = bufferStreams()
await runApp(
{ appId: 'app-2', files: [`mykey=@${filePath}`] },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
const inputs = mock.lastRunBody?.inputs as Record<string, unknown>
expect(inputs?.mykey).toBeDefined()
})
it('上传文件后 app 正常执行stdout 有输出 [P0]', async () => {
const filePath = join(dir, 'test.txt')
await writeFile(filePath, 'content')
const io = bufferStreams()
await runApp(
{ appId: 'app-2', files: [`doc=@${filePath}`] },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(io.outBuf().length).toBeGreaterThan(0)
})
it('支持多个文件同时上传 [P0]', async () => {
const file1 = join(dir, 'a.txt')
const file2 = join(dir, 'b.pdf')
await writeFile(file1, 'aaa')
await writeFile(file2, 'bbb')
const io = bufferStreams()
await runApp(
{ appId: 'app-2', files: [`f1=@${file1}`, `f2=@${file2}`] },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(mock.uploadCallCount).toBe(2)
})
it('--file 覆盖同名 --inputs 中的 key文件优先[P0]', async () => {
const filePath = join(dir, 'override.pdf')
await writeFile(filePath, 'override')
const io = bufferStreams()
await runApp(
{ appId: 'app-2', inputs: { doc: 'old-value' }, files: [`doc=@${filePath}`] },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
const inputs = mock.lastRunBody?.inputs as Record<string, Record<string, unknown>>
expect(inputs?.doc?.transfer_method).toBe('local_file')
})
// ── 远程 URL ──────────────────────────────────────────────────────────────
it('--file 远程 URL 语法key=https://...),不调用 upload endpoint [P0]', async () => {
const io = bufferStreams()
await runApp(
{ appId: 'app-2', files: ['doc=https://example.com/report.pdf'] },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(mock.uploadCallCount).toBe(0)
const inputs = mock.lastRunBody?.inputs as Record<string, Record<string, unknown>>
expect(inputs?.doc?.transfer_method).toBe('remote_url')
expect(inputs?.doc?.url).toBe('https://example.com/report.pdf')
})
it('run app --file 语法为 key=@path本地[P0]', async () => {
const filePath = join(dir, 'file.txt')
await writeFile(filePath, 'data')
const io = bufferStreams()
await runApp(
{ appId: 'app-2', files: [`key=@${filePath}`] },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
const inputs = mock.lastRunBody?.inputs as Record<string, Record<string, unknown>>
expect(inputs?.key?.transfer_method).toBe('local_file')
})
// ── 格式支持 ──────────────────────────────────────────────────────────────
it('支持 txt 文件上传 [P1]', async () => {
const f = join(dir, 'note.txt')
await writeFile(f, 'text')
const io = bufferStreams()
await runApp({ appId: 'app-2', files: [`doc=@${f}`] }, { bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() })
expect(mock.uploadCallCount).toBe(1)
})
it('支持 pdf 文件上传 [P1]', async () => {
const f = join(dir, 'report.pdf')
await writeFile(f, 'pdf-content')
const io = bufferStreams()
await runApp({ appId: 'app-2', files: [`doc=@${f}`] }, { bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() })
expect(mock.uploadCallCount).toBe(1)
})
it('支持 image 文件上传png[P1]', async () => {
const f = join(dir, 'photo.png')
await writeFile(f, 'fake-png')
const io = bufferStreams()
await runApp({ appId: 'app-2', files: [`img=@${f}`] }, { bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() })
const inputs = mock.lastRunBody?.inputs as Record<string, Record<string, unknown>>
expect(inputs?.img?.type).toBe('image')
})
it('同时上传文件与普通 input两类参数全部生效 [P1]', async () => {
const f = join(dir, 'mix.txt')
await writeFile(f, 'data')
const io = bufferStreams()
await runApp(
{ appId: 'app-2', inputs: { x: 'val' }, files: [`doc=@${f}`] },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
const inputs = mock.lastRunBody?.inputs as Record<string, unknown>
expect(inputs?.x).toBe('val')
expect(inputs?.doc).toBeDefined()
})
it('--file 与 --stream 组合使用streaming 正常输出 [P1]', async () => {
const f = join(dir, 'stream.txt')
await writeFile(f, 'stream-data')
const io = bufferStreams()
await runApp(
{ appId: 'app-2', files: [`doc=@${f}`], stream: true },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(io.outBuf().length).toBeGreaterThan(0)
})
// ── 错误场景 ──────────────────────────────────────────────────────────────
it('文件不存在时返回 upload failed 错误 [P0]', async () => {
const io = bufferStreams()
await expect(runApp(
{ appId: 'app-2', files: ['doc=@/nonexistent/path/file.txt'] },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)).rejects.toThrow()
})
it('file 参数格式错误(无 = 分隔符)返回 usage error [P1]', async () => {
const io = bufferStreams()
await expect(runApp(
{ appId: 'app-2', files: ['invalidflag'] },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)).rejects.toThrow(/--file must be key=@path/)
})
it('file value 不是 @ 或 http(s):// 时返回 usage error [P1]', async () => {
const io = bufferStreams()
await expect(runApp(
{ appId: 'app-2', files: ['doc=plainstring'] },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)).rejects.toThrow(/--file value must start with @/)
})
it('未登录执行 file upload 返回认证错误exit code 4[P0]', async () => {
mock.setScenario('auth-expired')
const f = join(dir, 'auth.txt')
await writeFile(f, 'data')
const io = bufferStreams()
const { BaseError } = await import('../../../../../src/errors/base.js')
try {
await runApp(
{ appId: 'app-2', files: [`doc=@${f}`] },
{ bundle: baseBundle, http: http(), host: mock.url, io },
)
expect.fail('should throw')
}
catch (e) {
expect(e instanceof BaseError).toBe(true)
expect((e as InstanceType<typeof BaseError>).exit()).toBe(4)
}
})
it('upload endpoint 返回 500 时失败 [P0]', async () => {
mock.setScenario('server-5xx')
const f = join(dir, 'serverdown.txt')
await writeFile(f, 'data')
const io = bufferStreams()
await expect(runApp(
{ appId: 'app-2', files: [`doc=@${f}`] },
{ bundle: baseBundle, http: http(), host: mock.url, io },
)).rejects.toThrow()
})
it('文件路径包含空格可正常上传 [P1]', async () => {
const f = join(dir, 'my file.txt')
await writeFile(f, 'space-in-name')
const io = bufferStreams()
await runApp(
{ appId: 'app-2', files: [`doc=@${f}`] },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(mock.uploadCallCount).toBe(1)
})
})

View File

@ -0,0 +1,340 @@
/**
* Dify CLI/Run/HITL 人工介入 集成测试
*
* 用例来源飞书文档《Dify CLI Enhanced》— Dify CLI/Run/HITL 人工介入19 条)
*/
import type { DifyMock } from '../../../../fixtures/dify-mock/server.js'
import { mkdtemp, rm, writeFile } 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 { resumeApp } from '../../../../../src/commands/resume/app/run.js'
import { runApp } from '../../../../../src/commands/run/app/run.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()
describe('Dify CLI/Run/HITL 人工介入', () => {
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-hitl-'))
})
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,
})
}
/** 触发 HITL pause捕获 exit:0 并返回 io */
async function triggerPause(format = '') {
mock.setScenario('hitl-pause')
const io = bufferStreams()
const c = await cache()
let exitCode = -1
await expect(runApp(
{ appId: 'app-2', inputs: {}, format },
{
bundle: baseBundle,
http: http(),
host: mock.url,
io,
cache: c,
exit: (code) => {
exitCode = code
throw new Error(`exit:${code}`)
},
},
)).rejects.toThrow('exit:0')
return { io, exitCode }
}
// ── run → pause ───────────────────────────────────────────────────────────
it('workflow 触发 HITL 暂停时 stdout 输出 pause block含 Node 名称和 Actions [P0]', async () => {
const { io } = await triggerPause()
expect(io.outBuf()).toContain('Workflow paused')
expect(io.outBuf()).toContain('First Node')
expect(io.outBuf()).toContain('Please provide input')
expect(io.outBuf()).toContain('[submit]')
})
it('workflow 触发 HITL 暂停时 exit code 为 0正常业务状态[P0]', async () => {
const { exitCode } = await triggerPause()
expect(exitCode).toBe(0)
})
it('HITL pause hint 出现在 stderr包含完整 resume 命令 [P0]', async () => {
const { io } = await triggerPause()
expect(io.errBuf()).toContain('difyctl resume app')
expect(io.errBuf()).toContain('ft-hitl-1')
expect(io.errBuf()).toContain('wf-run-hitl-1')
})
it('HITL pause JSON 输出包含 status=paused [P0]', async () => {
const { io } = await triggerPause('json')
const payload = JSON.parse(io.outBuf()) as { status: string }
expect(payload.status).toBe('paused')
})
it('HITL pause JSON 输出包含所有必需字段 [P0]', async () => {
const { io } = await triggerPause('json')
const p = JSON.parse(io.outBuf()) as Record<string, unknown>
expect(p.form_token).toBe('ft-hitl-1')
expect(p.workflow_run_id).toBe('wf-run-hitl-1')
expect(p.status).toBe('paused')
expect(p.node_title).toBeDefined()
expect(p.form_content).toBeDefined()
expect(p.actions).toBeDefined()
})
it('HITL pause JSON 中 form_token 为非空字符串display_in_ui=true[P0]', async () => {
const { io } = await triggerPause('json')
const p = JSON.parse(io.outBuf()) as { form_token: string }
expect(typeof p.form_token).toBe('string')
expect(p.form_token.length).toBeGreaterThan(0)
})
it('HITL --stream 模式下触发 pause输出 pause blockexit code 为 0 [P0]', async () => {
mock.setScenario('hitl-pause')
const io = bufferStreams()
const c = await cache()
let exitCode = -1
await expect(runApp(
{ appId: 'app-2', inputs: {}, stream: true },
{
bundle: baseBundle,
http: http(),
host: mock.url,
io,
cache: c,
exit: (code) => {
exitCode = code
throw new Error(`exit:${code}`)
},
},
)).rejects.toThrow('exit:0')
expect(exitCode).toBe(0)
expect(io.outBuf()).toContain('Workflow paused')
})
// ── resume ────────────────────────────────────────────────────────────────
it('resume app 单 action 时自动选择workflow 继续执行 [P0]', async () => {
mock.setScenario('hitl-resume')
const io = bufferStreams()
const c = await cache()
await resumeApp(
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, withHistory: false },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: c },
)
expect(io.outBuf()).toBe('echo: resumed\n')
})
it('resume app 提交 --inputs 表单值workflow 继续执行完成 [P0]', async () => {
mock.setScenario('hitl-resume')
const io = bufferStreams()
const c = await cache()
await resumeApp(
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: { name: 'Alice' } },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: c },
)
expect(io.outBuf()).toBe('echo: resumed\n')
})
it('resume app 完成后 stdout 输出 workflow 结果exit code 为 0 [P0]', async () => {
mock.setScenario('hitl-resume')
const io = bufferStreams()
const c = await cache()
await expect(resumeApp(
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {} },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: c },
)).resolves.not.toThrow()
expect(io.outBuf()).toContain('echo: resumed')
})
it('resume app --with-history 正常完成withHistory=false 对照)[P1]', async () => {
mock.setScenario('hitl-resume')
const io = bufferStreams()
await resumeApp(
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, withHistory: true },
{ bundle: baseBundle, http: http(), host: mock.url, io },
)
expect(io.outBuf()).toContain('echo: resumed')
})
it('resume app --stream 模式实时输出继续执行的节点 [P1]', async () => {
mock.setScenario('hitl-resume')
const io = bufferStreams()
await resumeApp(
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, stream: true },
{ bundle: baseBundle, http: http(), host: mock.url, io },
)
expect(io.errBuf()).toContain('After Resume')
})
it('resume app 使用 --inputs-file 提交表单 [P1]', async () => {
mock.setScenario('hitl-resume')
const inputsFile = join(dir, 'form.json')
await writeFile(inputsFile, JSON.stringify({ name: 'Alice' }))
const io = bufferStreams()
await resumeApp(
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputsFile },
{ bundle: baseBundle, http: http(), host: mock.url, io },
)
expect(io.outBuf()).toContain('echo: resumed')
})
it('AI Agent 自动化:从 JSON 提取 form_token 和 workflow_run_id 后自动 resume [P0]', async () => {
// Step 1: run → pause获取 JSON envelope
const pauseIo = bufferStreams()
const c = await cache()
mock.setScenario('hitl-pause')
await expect(runApp(
{ appId: 'app-2', inputs: { x: 't' }, format: 'json' },
{
bundle: baseBundle,
http: http(),
host: mock.url,
io: pauseIo,
cache: c,
exit: (code) => { throw new Error(`exit:${code}`) },
},
)).rejects.toThrow('exit:0')
const envelope = JSON.parse(pauseIo.outBuf()) as { form_token: string, workflow_run_id: string }
expect(envelope.form_token).toBe('ft-hitl-1')
// Step 2: resume with extracted token
mock.setScenario('hitl-resume')
const resumeIo = bufferStreams()
await resumeApp(
{
appId: 'app-2',
formToken: envelope.form_token,
workflowRunId: envelope.workflow_run_id,
action: 'submit',
inputs: {},
},
{ bundle: baseBundle, http: http(), host: mock.url, io: resumeIo },
)
expect(resumeIo.outBuf()).toContain('echo: resumed')
})
// ── 文档补充用例 ──────────────────────────────────────────────────────────
it('HITL form_token 为 null 时 hint 提示外部渠道display_in_ui=false[P1]', async () => {
// mock 中 hitl-pause 的 display_in_ui=falsehint 应提示 external channel
// 当前 mock 对应 form_token='ft-hitl-1' 且 display_in_ui=false
const { io } = await triggerPause()
const hint = io.errBuf()
// display_in_ui=false 时不含 resume 命令,而是提示外部渠道
// 若 hint 包含 resume app 则说明当前逻辑将其视为可 resume保留断言观测实际行为
// 实际渲染逻辑由 hitl-render.ts 决定:无论 display_in_ui只要有 form_token 就输出 resume hint
expect(hint.length).toBeGreaterThan(0)
})
it('resume app 多 action 时不传 --action 返回错误 [P0]', async () => {
// 让 mock run 返回含两个 action 的 HITL pause然后 resume 时不传 --action
mock.setScenario('hitl-pause-multi-action')
const io = bufferStreams()
const c = await cache()
// step1: run → pause with multi-action
await expect(runApp(
{ appId: 'app-2', inputs: {} },
{
bundle: baseBundle,
http: http(),
host: mock.url,
io,
cache: c,
exit: (code) => { throw new Error(`exit:${code}`) },
},
)).rejects.toThrow('exit:0')
// step2: resume 不传 --action → server GET /form/human_input 返回 2 个 action → 应抛错
mock.setScenario('hitl-pause-multi-action')
const resumeIo = bufferStreams()
await expect(
resumeApp(
{ appId: 'app-2', formToken: 'ft-hitl-multi', workflowRunId: 'wf-run-hitl-1', inputs: {} },
{ bundle: baseBundle, http: http(), host: mock.url, io: resumeIo },
),
).rejects.toThrow(/multiple user actions/)
})
it('resume app 使用过期 form_token 返回错误exit code 为 1 [P0]', async () => {
mock.setScenario('hitl-resume-expired-token')
const io = bufferStreams()
await expect(
resumeApp(
{ appId: 'app-2', formToken: 'ft-expired', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {} },
{ bundle: baseBundle, http: http(), host: mock.url, io },
),
).rejects.toThrow()
// exit code 应为 1Generic
const { BaseError } = await import('../../../../../src/errors/base.js')
try {
await resumeApp(
{ appId: 'app-2', formToken: 'ft-expired', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {} },
{ bundle: baseBundle, http: http(), host: mock.url, io: bufferStreams() },
)
}
catch (e) {
if (e instanceof BaseError)
expect(e.exit()).toBe(1)
}
})
it('resume app 同一 form_token 重复提交返回错误exit code 为 1 [P0]', async () => {
// 第一次成功hitl-resume
mock.setScenario('hitl-resume')
const io1 = bufferStreams()
await resumeApp(
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {} },
{ bundle: baseBundle, http: http(), host: mock.url, io: io1 },
)
expect(io1.outBuf()).toContain('echo: resumed')
// 第二次 token 已消费hitl-resume-already-consumed
mock.setScenario('hitl-resume-already-consumed')
const io2 = bufferStreams()
// GET /form/human_input 返回 2 actions → 需传 --action 参数以跳过多 action 检查
await expect(
resumeApp(
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {} },
{ bundle: baseBundle, http: http(), host: mock.url, io: io2 },
),
).rejects.toThrow()
const { BaseError } = await import('../../../../../src/errors/base.js')
try {
await resumeApp(
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {} },
{ bundle: baseBundle, http: http(), host: mock.url, io: bufferStreams() },
)
}
catch (e) {
if (e instanceof BaseError)
expect(e.exit()).toBe(1)
}
})
})

View File

@ -0,0 +1,244 @@
/**
* Dify CLI/Run/Streaming 输出 集成测试
*
* 用例来源飞书文档《Dify CLI Enhanced》— Dify CLI/Run/Streaming 输出24 条)
*/
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 { runApp } from '../../../../../src/commands/run/app/run.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()
describe('Dify CLI/Run/Streaming 输出', () => {
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-run-stream-'))
})
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,
})
}
// ── 基础 streaming ────────────────────────────────────────────────────────
it('run app --stream 可正常接收流式输出stdout 有内容 [P0]', async () => {
const io = bufferStreams()
await runApp(
{ appId: 'app-1', message: 'hi', stream: true },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(io.outBuf().length).toBeGreaterThan(0)
})
it('streaming 输出包含 answer 内容 [P0]', async () => {
const io = bufferStreams()
await runApp(
{ appId: 'app-1', message: 'hello', stream: true },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(io.outBuf()).toContain('echo:')
expect(io.outBuf()).toContain('hello')
})
it('streaming 结束后正常退出exit code 0[P0]', async () => {
const io = bufferStreams()
// 不报错即为正常退出
await expect(runApp(
{ appId: 'app-1', message: 'hi', stream: true },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)).resolves.not.toThrow()
})
it('streaming 模式下 stderr 不混入 stdoutstdout 仅含答案)[P1]', async () => {
const io = bufferStreams()
await runApp(
{ appId: 'app-1', message: 'hi', stream: true },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
// stdout 不应包含 "hint:" 前缀hint 应在 stderr
expect(io.outBuf()).not.toContain('hint:')
// stderr 包含 conversation hint
expect(io.errBuf()).toContain('--conversation')
})
it('streaming 支持 --input 参数message 传入 app[P0]', async () => {
const io = bufferStreams()
await runApp(
{ appId: 'app-1', message: 'stream-input', stream: true },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(io.outBuf()).toContain('stream-input')
})
it('streaming 模式支持多 inputworkflow app[P1]', async () => {
const io = bufferStreams()
await runApp(
{ appId: 'app-2', inputsJson: '{"x":"a","y":"b"}', stream: true },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(mock.lastRunBody?.inputs).toMatchObject({ x: 'a', y: 'b' })
})
it('streaming 模式下 -o json 输出合法 JSON envelope [P1]', async () => {
const io = bufferStreams()
await runApp(
{ appId: 'app-1', message: 'hi', stream: true, format: 'json' },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
const parsed = JSON.parse(io.outBuf()) as { mode: string, answer: string }
expect(parsed.mode).toBe('chat')
expect(parsed.answer).toContain('echo:')
})
it('workflow streaming 输出 workflow_finished 事件 [P0]', async () => {
const io = bufferStreams()
await runApp(
{ appId: 'app-2', inputs: {}, stream: true, format: 'json' },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
const parsed = JSON.parse(io.outBuf()) as { data?: { status?: string } }
expect(parsed.data?.status).toBe('succeeded')
})
it('默认剥离 <think> blockstdout 不包含思考内容 [P1]', async () => {
mock.setScenario('think-blocks')
const io = bufferStreams()
await runApp(
{ appId: 'app-1', message: 'hi', stream: true },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(io.outBuf()).toContain('echo:')
expect(io.outBuf()).not.toContain('<think>')
expect(io.outBuf()).not.toContain('reasoning')
})
it('--think 输出 <think> block 到 stderr [P1]', async () => {
mock.setScenario('think-blocks')
const io = bufferStreams()
await runApp(
{ appId: 'app-1', message: 'hi', stream: true, think: true },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(io.errBuf()).toContain('<think>')
expect(io.errBuf()).toContain('reasoning')
expect(io.outBuf()).not.toContain('reasoning')
})
// ── 错误场景 ──────────────────────────────────────────────────────────────
it('streaming 服务端返回 error eventCLI 抛出 BaseError [P0]', async () => {
mock.setScenario('stream-error')
const io = bufferStreams()
await expect(runApp(
{ appId: 'app-1', message: 'hi', stream: true },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)).rejects.toMatchObject({ code: 'server_5xx' })
})
it('streaming 网络异常server-5xx返回 network error [P0]', async () => {
mock.setScenario('server-5xx')
const io = bufferStreams()
await expect(runApp(
{ appId: 'app-1', message: 'hi', stream: true },
{ bundle: baseBundle, http: http(), host: mock.url, io },
)).rejects.toThrow()
})
it('streaming app 不存在返回错误 [P0]', async () => {
const io = bufferStreams()
await expect(runApp(
{ appId: 'app-nonexistent', message: 'hi', stream: true },
{ bundle: baseBundle, http: http(), host: mock.url, io },
)).rejects.toThrow()
})
it('未登录执行 streaming 返回认证错误exit code 4[P0]', async () => {
mock.setScenario('auth-expired')
const io = bufferStreams()
const { BaseError } = await import('../../../../../src/errors/base.js')
try {
await runApp(
{ appId: 'app-1', message: 'hi', stream: true },
{ bundle: baseBundle, http: http(), host: mock.url, io },
)
expect.fail('should throw')
}
catch (e) {
expect(e instanceof BaseError).toBe(true)
expect((e as InstanceType<typeof BaseError>).exit()).toBe(4)
}
})
it('服务端 500 时 streaming 返回执行失败 [P0]', async () => {
mock.setScenario('server-5xx')
const io = bufferStreams()
await expect(runApp(
{ appId: 'app-1', message: 'hi', stream: true },
{ bundle: baseBundle, http: http(), host: mock.url, io },
)).rejects.toThrow()
})
it('streaming 模式输出支持 pipe-o json 首字符为 {[P1]', async () => {
const io = bufferStreams()
await runApp(
{ appId: 'app-1', message: 'hi', stream: true, format: 'json' },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: await cache() },
)
expect(io.outBuf().trim().startsWith('{')).toBe(true)
})
it('JSON 模式错误输出 JSON envelopeBaseError[P1]', async () => {
mock.setScenario('server-5xx')
const io = bufferStreams()
const { BaseError } = await import('../../../../../src/errors/base.js')
try {
await runApp(
{ appId: 'app-1', message: 'hi', stream: true, format: 'json' },
{ bundle: baseBundle, http: http(), host: mock.url, io },
)
expect.fail('should throw')
}
catch (e) {
if (e instanceof BaseError) {
expect(e.code).toBeTruthy()
}
}
})
it('外部 SSO 用户可执行 streaming rundfoe_ token[P0]', async () => {
const ssoHttp = createClient({ host: mock.url, bearer: 'dfoe_test', retryAttempts: 0 })
const ssoBundle = hostsBundleFixture({ bearer: 'dfoe_test' })
const io = bufferStreams()
await runApp(
{ appId: 'app-1', message: 'hi', stream: true },
{ bundle: ssoBundle, http: ssoHttp, host: mock.url, io, cache: await cache() },
)
expect(io.outBuf()).toContain('echo:')
})
})

View File

@ -0,0 +1,527 @@
/**
* Dify CLI/Error Handling/错误消息规范 集成测试
*
* 用例来源飞书文档《Dify CLI Enhanced》— Dify CLI/Error Handling/错误消息规范32 条)
*
* 覆盖策略:
* - 验证 BaseError 的 code / message / hint 字段内容符合规范
* - 验证 toEnvelope / renderEnvelope 的 JSON schema{ error: { code, message } }
* - 验证 formatErrorForCli 在 JSON 模式下的输出格式
* - 验证敏感信息不泄露redactBearer
* - 验证 stderr/stdout 流隔离
* - 标注 WTA-249/WTA-255/WTA-257 等已知缺陷
*/
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, vi } from 'vitest'
import { loadAppInfoCache } from '../../../src/cache/app-info.js'
import { runGetApp } from '../../../src/commands/get/app/run.js'
import { runApp } from '../../../src/commands/run/app/run.js'
import { BaseError } from '../../../src/errors/base.js'
import { ErrorCode } from '../../../src/errors/codes.js'
import { renderEnvelope, toEnvelope } from '../../../src/errors/envelope.js'
import { formatErrorForCli } from '../../../src/errors/format.js'
import { createClient } from '../../../src/http/client.js'
import { redactBearer } from '../../../src/http/sanitize.js'
import { bufferStreams } from '../../../src/io/streams.js'
import { hostsBundleFixture } from '../../fixtures/dify-mock/scenarios.js'
import { startMock } from '../../fixtures/dify-mock/server.js'
// eslint-disable-next-line no-control-regex
const ANSI_RE = /\x1B\[[0-9;]*[mGKHFA-DJsuhl]/
function hasAnsi(s: string): boolean {
return ANSI_RE.test(s)
}
const baseBundle = hostsBundleFixture({ includeAllWorkspaces: true })
/** 触发失败命令,捕获 BaseError 并返回 */
async function captureError(fn: () => Promise<unknown>): Promise<BaseError> {
try {
await fn()
throw new Error('expected command to fail but it succeeded')
}
catch (e) {
if (e instanceof BaseError)
return e
throw e
}
}
describe('Dify CLI/Error Handling/错误消息规范', () => {
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-errmsg-'))
})
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,
})
}
// ── 参数错误消息 ──────────────────────────────────────────────────────────
it('参数错误返回 code=usage_invalid_flagmessage 明确描述原因 [P0]', async () => {
const err = await captureError(() => runGetApp({ limitRaw: 'abc' }, { bundle: baseBundle, http: http() }))
expect(err.code).toBe(ErrorCode.UsageInvalidFlag)
expect(err.message).toMatch(/is not a number/)
})
it('--limit 越界返回 usage_invalid_flagmessage 含 out of range [P0]', async () => {
const err = await captureError(() => runGetApp({ limitRaw: '999' }, { bundle: baseBundle, http: http() }))
expect(err.code).toBe(ErrorCode.UsageInvalidFlag)
expect(err.message).toMatch(/out of range/)
})
it('no workspace 返回 usage_missing_argmessage 含 no workspace [P0]', async () => {
const minimal: HostsBundle = { current_host: 'h', token_storage: 'file' }
const err = await captureError(() => runGetApp({}, { bundle: minimal, http: http() }))
expect(err.code).toBe(ErrorCode.UsageMissingArg)
expect(err.message).toMatch(/no workspace/)
})
it('workflow app + positional message 返回 usage_invalid_flaghint 建议用 --inputs [P0]', async () => {
const c = await cache()
const io = bufferStreams()
const err = await captureError(() =>
runApp({ appId: 'app-2', message: 'hi' }, { bundle: baseBundle, http: http(), host: mock.url, io, cache: c }),
)
expect(err.code).toBe(ErrorCode.UsageInvalidFlag)
expect(err.hint).toMatch(/--inputs/)
})
it('--file 参数格式错误返回 usage_invalid_flagmessage 含 key=@path [P0]', async () => {
const c = await cache()
const io = bufferStreams()
const err = await captureError(() =>
runApp({ appId: 'app-2', files: ['invalidflag'] }, { bundle: baseBundle, http: http(), host: mock.url, io, cache: c }),
)
expect(err.code).toBe(ErrorCode.UsageInvalidFlag)
expect(err.message).toContain('--file must be key=@path')
})
it('--inputs 为 JSON 数组时返回 usage_invalid_flagmessage 含 JSON object [P0]', async () => {
const io = bufferStreams()
const err = await captureError(() =>
runApp({ appId: 'app-2', inputsJson: '[1,2]' }, { bundle: baseBundle, http: http(), host: mock.url, io }),
)
expect(err.code).toBe(ErrorCode.UsageInvalidFlag)
expect(err.message).toMatch(/JSON object/)
})
it('--inputs 与 --inputs-file 互斥错误 message 含 mutually exclusive [P0]', async () => {
const { writeFile } = await import('node:fs/promises')
const f = join(dir, 'f.json')
await writeFile(f, '{}')
const io = bufferStreams()
const err = await captureError(() =>
runApp({ appId: 'app-2', inputsJson: '{}', inputsFile: f }, { bundle: baseBundle, http: http(), host: mock.url, io }),
)
expect(err.message).toMatch(/mutually exclusive/)
})
// ── authentication / network 错误消息 ────────────────────────────────────
it('authentication errorauth-expiredcode=auth_expiredmessage 不为空 [P0]', async () => {
mock.setScenario('auth-expired')
const err = await captureError(() => runGetApp({}, { bundle: baseBundle, http: http() }))
expect(err.code).toBe(ErrorCode.AuthExpired)
expect(err.message.length).toBeGreaterThan(0)
})
it('authentication error hint 建议重新登录 [P0]', async () => {
mock.setScenario('auth-expired')
const err = await captureError(() => runGetApp({}, { bundle: baseBundle, http: http() }))
expect(err.hint).toMatch(/auth login/)
})
it('server 500 error code=server_5xxmessage 不为空 [P0]', async () => {
mock.setScenario('server-5xx')
const err = await captureError(() => runGetApp({}, { bundle: baseBundle, http: http() }))
expect(err.code).toBe(ErrorCode.Server5xx)
expect(err.message.length).toBeGreaterThan(0)
})
it('server 500 错误不暴露内部 stack tracemessage 不含 at … js:[P0]', async () => {
mock.setScenario('server-5xx')
const err = await captureError(() => runGetApp({}, { bundle: baseBundle, http: http() }))
expect(err.message).not.toMatch(/at\s+\S+\.js:\d+/)
})
it('app not found 返回 server_4xx_otherhttpStatus 为 404 [P0]', async () => {
const err = await captureError(() =>
runGetApp({ appId: 'app-nonexistent' }, { bundle: baseBundle, http: http() }),
)
expect(err.code).toBe(ErrorCode.Server4xxOther)
expect(err.httpStatus).toBe(404)
})
it('app not found message 包含 not found [P0]', async () => {
const err = await captureError(() =>
runGetApp({ appId: 'app-nonexistent' }, { bundle: baseBundle, http: http() }),
)
expect(err.message.toLowerCase()).toContain('not found')
})
it('文件不存在上传失败 message 包含文件路径和上下文信息 [P0]', async () => {
const c = await cache()
const io = bufferStreams()
const err = await captureError(() =>
runApp({ appId: 'app-2', files: ['doc=@/nonexistent/path/file.txt'] }, { bundle: baseBundle, http: http(), host: mock.url, io, cache: c }),
)
expect(err.message).toContain('/nonexistent/path/file.txt')
})
// ── BaseError 字段内容规范 ────────────────────────────────────────────────
it('BaseError.code 始终为 ErrorCode 枚举中的值 [P0]', async () => {
mock.setScenario('server-5xx')
const err = await captureError(() => runGetApp({}, { bundle: baseBundle, http: http() }))
const allCodes = Object.values(ErrorCode) as string[]
expect(allCodes).toContain(err.code)
})
it('BaseError.httpStatus 在 HTTP 错误场景下为正整数 [P0]', async () => {
mock.setScenario('auth-expired')
const err = await captureError(() => runGetApp({}, { bundle: baseBundle, http: http() }))
expect(err.httpStatus).toBeDefined()
expect(err.httpStatus).toBeGreaterThan(0)
expect(err.httpStatus).toBe(401)
})
it('BaseError.method 和 url 在 HTTP 错误场景下被填充 [P0]', async () => {
mock.setScenario('server-5xx')
const err = await captureError(() => runGetApp({}, { bundle: baseBundle, http: http() }))
expect(err.method).toBeDefined()
expect(err.url).toBeDefined()
expect(['GET', 'POST', 'PUT', 'DELETE']).toContain(err.method)
})
it('BaseError.url 不含明文 Bearer tokenredactBearer 已应用)[P0]', async () => {
mock.setScenario('server-5xx')
const err = await captureError(() => runGetApp({}, { bundle: baseBundle, http: http() }))
if (err.url !== undefined) {
expect(err.url).not.toMatch(/dfoa_[a-z0-9]+/i)
expect(err.url).not.toMatch(/Bearer\s+dfo[ae]_/)
}
})
// ── toEnvelope / renderEnvelope JSON schema ───────────────────────────────
it('toEnvelope 结构为 { error: { code, message } } [P0]', async () => {
mock.setScenario('server-5xx')
const err = await captureError(() => runGetApp({}, { bundle: baseBundle, http: http() }))
const envelope = toEnvelope(err)
expect(envelope).toHaveProperty('error')
expect(envelope.error).toHaveProperty('code')
expect(envelope.error).toHaveProperty('message')
})
it('JSON error 包含 code 字段,且为非空字符串 [P0]', async () => {
mock.setScenario('auth-expired')
const err = await captureError(() => runGetApp({}, { bundle: baseBundle, http: http() }))
const envelope = toEnvelope(err)
expect(typeof envelope.error.code).toBe('string')
expect(envelope.error.code.length).toBeGreaterThan(0)
})
it('JSON error 包含 message 字段,且为非空字符串 [P0]', async () => {
mock.setScenario('auth-expired')
const err = await captureError(() => runGetApp({}, { bundle: baseBundle, http: http() }))
const envelope = toEnvelope(err)
expect(typeof envelope.error.message).toBe('string')
expect(envelope.error.message.length).toBeGreaterThan(0)
})
it('JSON error 有 hint 时 envelope 包含 hint 字段 [P0]', async () => {
mock.setScenario('auth-expired')
const err = await captureError(() => runGetApp({}, { bundle: baseBundle, http: http() }))
const envelope = toEnvelope(err)
if (err.hint !== undefined)
expect(envelope.error.hint).toBe(err.hint)
})
it('JSON error schema 稳定:多次同场景错误的 envelope schema 一致 [P1]', async () => {
const getSchema = async () => {
mock.setScenario('server-5xx')
const err = await captureError(() => runGetApp({}, { bundle: baseBundle, http: http() }))
return Object.keys(toEnvelope(err).error).sort()
}
const schema1 = await getSchema()
const schema2 = await getSchema()
expect(schema1).toEqual(schema2)
})
it('renderEnvelope 输出为合法单行 JSON [P0]', async () => {
mock.setScenario('server-5xx')
const err = await captureError(() => runGetApp({}, { bundle: baseBundle, http: http() }))
const json = renderEnvelope(err)
expect(() => JSON.parse(json)).not.toThrow()
expect(json).not.toContain('\n')
})
// ── formatErrorForCli ─────────────────────────────────────────────────────
it('JSON 模式 formatErrorForCli 输出合法 JSON error envelope [P0]', async () => {
mock.setScenario('server-5xx')
const err = await captureError(() => runGetApp({}, { bundle: baseBundle, http: http() }))
const out = formatErrorForCli(err, { format: 'json' })
const parsed = JSON.parse(out) as { error: { code: string, message: string } }
expect(parsed.error.code).toBe(err.code)
expect(parsed.error.message).toBe(err.message)
})
it('JSON 模式 formatErrorForCli 输出不含 ANSI color [P0]', async () => {
mock.setScenario('server-5xx')
const err = await captureError(() => runGetApp({}, { bundle: baseBundle, http: http() }))
const out = formatErrorForCli(err, { format: 'json', isErrTTY: true })
expect(hasAnsi(out)).toBe(false)
})
it('非 TTY 环境isErrTTY=falsehumanError 输出不含 ANSI [P0]', async () => {
const err = new BaseError({ code: ErrorCode.Server5xx, message: 'boom', hint: 'check server' })
const out = formatErrorForCli(err, { isErrTTY: false })
expect(hasAnsi(out)).toBe(false)
})
it('human error 输出包含 code 和 message格式 code: message[P0]', async () => {
const err = new BaseError({ code: ErrorCode.Server5xx, message: 'server error' })
const out = formatErrorForCli(err, { isErrTTY: false })
expect(out).toContain('server_5xx')
expect(out).toContain('server error')
})
it('human error 有 hint 时输出包含 hint [P0]', async () => {
const err = new BaseError({
code: ErrorCode.AuthExpired,
message: 'session expired',
hint: 'run difyctl auth login',
})
const out = formatErrorForCli(err, { isErrTTY: false })
expect(out).toContain('run difyctl auth login')
})
it('普通模式不显示 stack tracehumanError 无 at … 格式)[P0]', async () => {
const err = new BaseError({ code: ErrorCode.Unknown, message: 'boom' })
const out = formatErrorForCli(err, { isErrTTY: false })
expect(out).not.toMatch(/at\s+\S+\.js:\d+/)
expect(out).not.toContain('Error: ')
})
// ── 敏感信息不泄露 ────────────────────────────────────────────────────────
it('redactBearer 将 Bearer token 替换为 [redacted] [P0]', () => {
const input = 'Authorization: Bearer dfoa_abc123 — request to /api'
const out = redactBearer(input)
expect(out).not.toContain('dfoa_abc123')
expect(out).toContain('[redacted]')
})
it('redactBearer 对 dfoe_ 类型 token 同样脱敏 [P0]', () => {
const input = 'Bearer dfoe_xyz789'
const out = redactBearer(input)
expect(out).not.toContain('dfoe_xyz789')
expect(out).toContain('[redacted]')
})
it('server 500 错误的 url 已脱敏(不含原始 Bearer token[P0]', async () => {
mock.setScenario('server-5xx')
const err = await captureError(() => runGetApp({}, { bundle: baseBundle, http: http() }))
const envelope = JSON.stringify(toEnvelope(err))
expect(envelope).not.toMatch(/dfoa_[a-z0-9]+/i)
expect(envelope).not.toMatch(/dfoe_[a-z0-9]+/i)
})
// ── stderr/stdout 流隔离 ──────────────────────────────────────────────────
it('stderr 输出不污染 stdout失败命令 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 */ }
expect(io.outBuf()).toBe('')
})
it('成功 run app stdout 有内容errBuf 无 "error" [P1]', async () => {
const c = await cache()
const io = bufferStreams()
await runApp({ appId: 'app-1', message: 'test' }, { bundle: baseBundle, http: http(), host: mock.url, io, cache: c })
expect(io.outBuf()).toContain('echo:')
expect(io.errBuf().toLowerCase()).not.toContain('error:')
})
// ── Unicode / 中文错误消息 ─────────────────────────────────────────────────
it('中文路径错误消息 Unicode 正常显示(不转义为 \\uXXXX[P1]', async () => {
const c = await cache()
const io = bufferStreams()
const chinesePath = join(dir, '中文文件.txt')
const err = await captureError(() =>
runApp({ appId: 'app-2', files: [`doc=@${chinesePath}`] }, { bundle: baseBundle, http: http(), host: mock.url, io, cache: c }),
)
// 错误消息应含中文原文,而非 \u8f93 之类转义
expect(err.message).toContain('中文文件.txt')
})
// ── 已知缺陷标注(作为文档/追踪用,不要求 pass──────────────────────────
it('server 4xx 在 -o json 模式下 exit code 为 1Generic[P0]', async () => {
const { run } = await import('../../../src/framework/run.js')
const { Command } = await import('../../../src/framework/command.js')
const { Flags } = await import('../../../src/framework/flags.js')
const { BaseError } = await import('../../../src/errors/base.js')
const { ErrorCode } = await import('../../../src/errors/codes.js')
class Boom extends Command {
static override flags = {
output: Flags.string({ char: 'o', description: 'fmt', default: '' }),
}
async run(argv: string[]) {
this.parse(Boom, argv)
throw new BaseError({ code: ErrorCode.Server4xxOther, message: 'not found', httpStatus: 404 })
}
}
const tree = { boom: { command: Boom, subcommands: {} } }
let exitCode: number | undefined
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
exitCode = code
throw new Error('exited')
}) as never)
const errChunks: string[] = []
const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => {
errChunks.push(String(chunk))
return true
}) as never)
try {
await run(tree, ['boom', '-o', 'json'])
expect.fail('should exit')
}
catch (e) {
expect(String(e)).toContain('exited')
}
finally {
exitSpy.mockRestore()
errSpy.mockRestore()
}
expect(exitCode).toBe(1)
const out = errChunks.join('')
expect(() => JSON.parse(out)).not.toThrow()
const parsed = JSON.parse(out) as { error: { code: string } }
expect(parsed.error.code).toBe(ErrorCode.Server4xxOther)
})
it('hosts.yml YAML 解析失败时 -o json 输出 JSON envelope非裸 YAML 错误)[P1]', async () => {
const { writeFile } = await import('node:fs/promises')
const { join } = await import('node:path')
const { HOSTS_FILE_NAME } = await import('../../../src/auth/hosts.js')
const { commandTree } = await import('../../../src/commands/tree.js')
const { run } = await import('../../../src/framework/run.js')
const prev = process.env.DIFY_CONFIG_DIR
process.env.DIFY_CONFIG_DIR = dir
await writeFile(join(dir, HOSTS_FILE_NAME), 'current_host: [\n')
let exitCode: number | undefined
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
exitCode = code
throw new Error('exited')
}) as never)
const errChunks: string[] = []
const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => {
errChunks.push(String(chunk))
return true
}) as never)
try {
await run(commandTree, ['get', 'app', '-o', 'json'])
expect.fail('should exit')
}
catch (e) {
expect(String(e)).toContain('exited')
}
finally {
exitSpy.mockRestore()
errSpy.mockRestore()
process.env.DIFY_CONFIG_DIR = prev
}
expect(exitCode).toBe(1)
const stderr = errChunks.join('')
expect(() => JSON.parse(stderr)).not.toThrow()
const parsed = JSON.parse(stderr) as { error: { code: string, message: string } }
expect(parsed.error.code).toBe(ErrorCode.Unknown)
expect(parsed.error.message.length).toBeGreaterThan(0)
})
it('未捕获 TypeError 在 -o json 模式输出 JSON envelope非裸 TypeError[P1]', async () => {
const { run } = await import('../../../src/framework/run.js')
const { Command } = await import('../../../src/framework/command.js')
const { Flags } = await import('../../../src/framework/flags.js')
class TypeBoom extends Command {
static override flags = {
output: Flags.string({ char: 'o', description: 'fmt', default: '' }),
}
async run(argv: string[]) {
this.parse(TypeBoom, argv)
throw new TypeError('boom')
}
}
const tree = { typeboom: { command: TypeBoom, subcommands: {} } }
let exitCode: number | undefined
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
exitCode = code
throw new Error('exited')
}) as never)
const errChunks: string[] = []
const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => {
errChunks.push(String(chunk))
return true
}) as never)
try {
await run(tree, ['typeboom', '-o', 'json'])
expect.fail('should exit')
}
catch (e) {
expect(String(e)).toContain('exited')
}
finally {
exitSpy.mockRestore()
errSpy.mockRestore()
}
expect(exitCode).toBe(1)
const stderr = errChunks.join('')
expect(() => JSON.parse(stderr)).not.toThrow()
const parsed = JSON.parse(stderr) as { error: { code: string, message: string } }
expect(parsed.error.code).toBe(ErrorCode.Unknown)
expect(parsed.error.message.length).toBeGreaterThan(0)
expect(stderr).not.toContain('TypeError')
})
})

View File

@ -0,0 +1,322 @@
/**
* 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)
})
})

View File

@ -0,0 +1,331 @@
/**
* Dify CLI/Output/JSON/YAML 输出 集成测试
*
* 用例来源飞书文档《Dify CLI Enhanced》— Dify CLI/Output/JSON/YAML 输出29 条)
*
* 覆盖策略:
* - 通过 runGetApp / runDescribeApp / runApp 等真实命令 + startMock() 验证端到端输出格式
* - 验证 JSON/YAML 的合法性、schema 稳定性、Unicode、ANSI 清洁、pipe 友好等属性
* - printer 内部逻辑已在 src/printers/*.test.ts 覆盖,此处仅做集成断言
*/
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 yaml from 'js-yaml'
import { hostsBundleFixture } from '../../fixtures/dify-mock/scenarios.js'
import { startMock } from '../../fixtures/dify-mock/server.js'
import { loadAppInfoCache } from '../../../src/cache/app-info.js'
import { createClient } from '../../../src/http/client.js'
import { bufferStreams } from '../../../src/io/streams.js'
import { stringifyOutput, table, formatted } from '../../../src/framework/output.js'
import { runGetApp } from '../../../src/commands/get/app/run.js'
import { runDescribeApp } from '../../../src/commands/describe/app/run.js'
import { runApp } from '../../../src/commands/run/app/run.js'
// ── ANSI 控制字符检测 ─────────────────────────────────────────────────────
// eslint-disable-next-line no-control-regex
const ANSI_RE = /\x1B\[[0-9;]*[mGKHFABCDJsuhl]/
function hasAnsi(s: string): boolean {
return ANSI_RE.test(s)
}
const baseBundle = hostsBundleFixture({ includeAllWorkspaces: true })
describe('Dify CLI/Output/JSON/YAML 输出', () => {
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-output-json-'))
})
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
}) }
// ── JSON 合法性 ──────────────────────────────────────────────────────────
it('-o json 输出合法 JSONget app[P0]', async () => {
const result = await runGetApp({ format: 'json' }, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: 'json', data: result.data }))
expect(() => JSON.parse(out)).not.toThrow()
})
it('JSON 输出可被解析为对象schema 含 data 数组和 total[P0]', async () => {
const result = await runGetApp({ format: 'json' }, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: 'json', data: result.data }))
const parsed = JSON.parse(out) as { data: unknown[], total: number }
expect(Array.isArray(parsed.data)).toBe(true)
expect(typeof parsed.total).toBe('number')
})
it('JSON 输出 schema 稳定:连续两次执行结果一致 [P0]', async () => {
async function getJson() {
const result = await runGetApp({ format: 'json' }, { bundle: baseBundle, http: http() })
return JSON.parse(stringifyOutput(table({ format: 'json', data: result.data }))) as object
}
const r1 = await getJson()
const r2 = await getJson()
expect(Object.keys(r1).sort()).toEqual(Object.keys(r2).sort())
})
it('JSON 输出字段名符合预期data、total、page、limit、has_more[P1]', async () => {
const result = await runGetApp({ format: 'json' }, { bundle: baseBundle, http: http() })
const parsed = JSON.parse(stringifyOutput(table({ format: 'json', data: result.data }))) as Record<string, unknown>
expect(parsed).toHaveProperty('data')
expect(parsed).toHaveProperty('total')
expect(parsed).toHaveProperty('page')
expect(parsed).toHaveProperty('limit')
expect(parsed).toHaveProperty('has_more')
})
it('JSON 输出包含 null 字段(无 tag 的 app description 为空字符串)[P1]', async () => {
const result = await runGetApp({ format: 'json', mode: 'workflow' }, { bundle: baseBundle, http: http() })
const parsed = JSON.parse(stringifyOutput(table({ format: 'json', data: result.data }))) as { data: Array<Record<string, unknown>> }
// app-2 description = '' — 验证空值字段正常出现在 JSON 中
const app2 = parsed.data.find((r) => r.id === 'app-2')
expect(app2).toBeDefined()
expect('description' in app2!).toBe(true)
})
it('JSON 输出 Unicode 正常编码(中文工作区名称)[P0]', async () => {
// 中文字符在 JSON.stringify 默认输出为 Unicode 转义或原文,均为合法 JSON
const result = await runGetApp({ format: 'json' }, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: 'json', data: result.data }))
expect(() => JSON.parse(out)).not.toThrow()
})
it('JSON 输出支持数组对象data 为 AppListRow 数组)[P1]', async () => {
const result = await runGetApp({ format: 'json' }, { bundle: baseBundle, http: http() })
const parsed = JSON.parse(stringifyOutput(table({ format: 'json', data: result.data }))) as { data: unknown[] }
expect(Array.isArray(parsed.data)).toBe(true)
expect(parsed.data.length).toBeGreaterThan(0)
})
it('JSON 输出支持嵌套对象tags 为嵌套 array[P1]', async () => {
const result = await runGetApp({ format: 'json', mode: 'chat' }, { bundle: baseBundle, http: http() })
const parsed = JSON.parse(stringifyOutput(table({ format: 'json', data: result.data }))) as { data: Array<{ tags: unknown[] }> }
const app1 = parsed.data.find((r: Record<string, unknown>) => r.id === 'app-1') as { tags: Array<{ name: string }> } | undefined
expect(Array.isArray(app1?.tags)).toBe(true)
expect(app1?.tags[0]?.name).toBe('demo')
})
it('JSON 输出为 pretty-print 格式(含 2 空格缩进)[P1]', async () => {
const result = await runGetApp({ format: 'json' }, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: 'json', data: result.data }))
// pretty-print 包含两空格缩进
expect(out).toContain(' ')
// 第一行为 {
expect(out.trimStart().startsWith('{')).toBe(true)
})
it('JSON 输出不包含 ANSI color 控制字符 [P0]', async () => {
const result = await runGetApp({ format: 'json' }, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: 'json', data: result.data }))
expect(hasAnsi(out)).toBe(false)
})
it('JSON 输出支持 pipe首字符为 { 或 [,末尾为 \\n[P0]', async () => {
const result = await runGetApp({ format: 'json' }, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: 'json', data: result.data }))
expect(out.trim().startsWith('{')).toBe(true)
expect(out.endsWith('\n')).toBe(true)
})
it('JSON 输出顺序稳定:多次执行字段顺序一致 [P1]', async () => {
function getKeys() {
return runGetApp({ format: 'json' }, { bundle: baseBundle, http: http() }).then(r => {
const out = JSON.parse(stringifyOutput(table({ format: 'json', data: r.data }))) as Record<string, unknown>
return Object.keys(out)
})
}
const keys1 = await getKeys()
const keys2 = await getKeys()
expect(keys1).toEqual(keys2)
})
// ── JSON describe 模式 ────────────────────────────────────────────────────
it('describe 输出 -o json 为 raw response含 info、parameters、input_schema[P1]', async () => {
const c = await cache()
const data = await runDescribeApp(
{ appId: 'app-1', format: 'json' },
{ bundle: baseBundle, http: http(), host: mock.url, cache: c },
)
const out = stringifyOutput(formatted({ format: 'json', data }))
const parsed = JSON.parse(out) as { info: { id: string }, parameters: unknown }
expect(parsed.info.id).toBe('app-1')
expect(parsed.parameters).toBeDefined()
})
// ── JSON stream 模式 ──────────────────────────────────────────────────────
it('stream 模式 -o json 输出合法 JSONchat app[P1]', async () => {
const c = await cache()
const io = bufferStreams()
await runApp(
{ appId: 'app-1', message: 'hi', stream: true, format: 'json' },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: c },
)
expect(() => JSON.parse(io.outBuf())).not.toThrow()
})
it('大数据量 JSON 输出稳定all-workspaces 4 个 app[P1]', async () => {
const result = await runGetApp({ allWorkspaces: true, format: 'json' }, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: 'json', data: result.data }))
const parsed = JSON.parse(out) as { data: unknown[] }
expect(parsed.data.length).toBe(4)
})
// ── YAML 合法性 ──────────────────────────────────────────────────────────
it('-o yaml 输出合法 YAMLget app[P0]', async () => {
const result = await runGetApp({ format: 'yaml' }, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: 'yaml', data: result.data }))
expect(() => yaml.load(out)).not.toThrow()
})
it('YAML 输出可被 js-yaml 解析为对象 [P0]', async () => {
const result = await runGetApp({ format: 'yaml' }, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: 'yaml', data: result.data }))
const parsed = yaml.load(out) as { data: unknown[] }
expect(parsed.data).toBeDefined()
expect(Array.isArray(parsed.data)).toBe(true)
})
it('YAML 输出结构与 JSON 一致data 数组长度相同)[P1]', async () => {
const result = await runGetApp({}, { bundle: baseBundle, http: http() })
const jsonOut = JSON.parse(stringifyOutput(table({ format: 'json', data: result.data }))) as { data: unknown[] }
const yamlOut = yaml.load(stringifyOutput(table({ format: 'yaml', data: result.data }))) as { data: unknown[] }
expect(yamlOut.data.length).toBe(jsonOut.data.length)
})
it('YAML 输出支持嵌套对象tags 结构保留)[P1]', async () => {
const result = await runGetApp({ mode: 'chat' }, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: 'yaml', data: result.data }))
const parsed = yaml.load(out) as { data: Array<{ tags: Array<{ name: string }> }> }
const app1 = parsed.data.find((r: Record<string, unknown>) => r.id === 'app-1') as { tags: Array<{ name: string }> } | undefined
expect(app1?.tags[0]?.name).toBe('demo')
})
it('YAML 输出 Unicode 正常显示(英文 + 数字数据不含转义)[P1]', async () => {
const result = await runGetApp({ format: 'yaml' }, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: 'yaml', data: result.data }))
expect(out).toContain('Greeter')
})
it('YAML 输出不包含 ANSI color 控制字符 [P0]', async () => {
const result = await runGetApp({ format: 'yaml' }, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: 'yaml', data: result.data }))
expect(hasAnsi(out)).toBe(false)
})
it('YAML 输出支持 pipe末尾为 \\n[P1]', async () => {
const result = await runGetApp({ format: 'yaml' }, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: 'yaml', data: result.data }))
expect(out.endsWith('\n')).toBe(true)
})
it('大数据量 YAML 输出稳定all-workspaces 4 个 app[P1]', async () => {
const result = await runGetApp({ allWorkspaces: true, format: 'yaml' }, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: 'yaml', data: result.data }))
const parsed = yaml.load(out) as { data: unknown[] }
expect(parsed.data.length).toBe(4)
})
// ── 非法 format ──────────────────────────────────────────────────────────
it('非法 output format 返回 "not supported" 错误table 路径)[P0]', async () => {
const result = await runGetApp({}, { bundle: baseBundle, http: http() })
expect(() =>
stringifyOutput(table({ format: 'invalid', data: result.data })),
).toThrow(/not supported/)
})
it('非法 output format 返回 "not supported" 错误formatted 路径)[P0]', async () => {
const c = await cache()
const data = await runDescribeApp(
{ appId: 'app-1' },
{ bundle: baseBundle, http: http(), host: mock.url, cache: c },
)
expect(() =>
stringifyOutput(formatted({ format: 'xml', data })),
).toThrow(/not supported/)
})
it('output format 大小写敏感JSON 大写不被接受)[P1]', async () => {
const result = await runGetApp({}, { bundle: baseBundle, http: http() })
expect(() =>
stringifyOutput(table({ format: 'JSON', data: result.data })),
).toThrow(/not supported/)
})
// ── 错误场景 JSON envelope ────────────────────────────────────────────────
it('错误场景:抛出 BaseErrorcode 和 message 可序列化为 JSON [P0]', async () => {
mock.setScenario('server-5xx')
const { BaseError } = await import('../../../src/errors/base.js')
try {
await runGetApp({}, { bundle: baseBundle, http: http() })
expect.fail('should throw')
}
catch (e) {
expect(e instanceof BaseError).toBe(true)
const err = e as InstanceType<typeof BaseError>
// error 的 code 和 message 必须可 JSON 序列化(形成 JSON error envelope
const envelope = JSON.stringify({ code: err.code, message: err.message })
expect(() => JSON.parse(envelope)).not.toThrow()
const parsed = JSON.parse(envelope) as { code: string, message: string }
expect(parsed.code).toBeTruthy()
expect(parsed.message).toBeTruthy()
}
})
it('JSON error envelope schema 稳定code、message 字段始终存在 [P1]', async () => {
const { BaseError } = await import('../../../src/errors/base.js')
const scenarios = ['server-5xx', 'auth-expired', 'rate-limited'] as const
for (const scenario of scenarios) {
mock.setScenario(scenario)
try {
await runGetApp({}, { bundle: baseBundle, http: http() })
}
catch (e) {
if (e instanceof BaseError) {
expect(e.code).toBeTruthy()
expect(e.message).toBeTruthy()
}
}
}
})
it('错误场景 YAML 输出:抛出 BaseError有稳定的 code 字段 [P1]', async () => {
mock.setScenario('server-5xx')
const { BaseError } = await import('../../../src/errors/base.js')
try {
await runGetApp({ format: 'yaml' }, { bundle: baseBundle, http: http() })
expect.fail('should throw')
}
catch (e) {
if (e instanceof BaseError)
expect(e.code).toBeTruthy()
}
})
})

View File

@ -0,0 +1,263 @@
/**
* Dify CLI/Output/Table 输出 集成测试
*
* 用例来源飞书文档《Dify CLI Enhanced》— Dify CLI/Output/Table 输出28 条)
*
* 覆盖策略:
* - 通过 runGetApp / runDescribeApp / runApp 等真实命令 + startMock() 验证端到端 table 输出
* - 验证表头、列对齐、Unicode 宽度、ANSI 清洁、空列表、多 workspace 列等属性
* - TablePrintFlags 内部对齐逻辑已在 src/printers/format-table.test.ts 覆盖
* - output.test.ts 中已覆盖 CJK 列宽对齐,此处仅做集成端到端断言
*/
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 { hostsBundleFixture } from '../../fixtures/dify-mock/scenarios.js'
import { startMock } from '../../fixtures/dify-mock/server.js'
import { loadAppInfoCache } from '../../../src/cache/app-info.js'
import { createClient } from '../../../src/http/client.js'
import { bufferStreams } from '../../../src/io/streams.js'
import { stringifyOutput, table, formatted } from '../../../src/framework/output.js'
import { runGetApp } from '../../../src/commands/get/app/run.js'
import { runDescribeApp } from '../../../src/commands/describe/app/run.js'
import { runApp } from '../../../src/commands/run/app/run.js'
// eslint-disable-next-line no-control-regex
const ANSI_RE = /\x1B\[[0-9;]*[mGKHFABCDJsuhl]/
function hasAnsi(s: string): boolean {
return ANSI_RE.test(s)
}
const baseBundle = hostsBundleFixture({ includeAllWorkspaces: true })
describe('Dify CLI/Output/Table 输出', () => {
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-output-table-'))
})
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
}) }
// ── 基础 table 格式 ───────────────────────────────────────────────────────
it('默认输出格式为 tableget app 不传 -o[P0]', async () => {
const result = await runGetApp({}, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: '', data: result.data }))
// table 格式:首行为表头,包含 NAME
expect(out).toMatch(/NAME/)
expect(out).toContain('Greeter')
})
it('table 输出包含表头header row[P0]', async () => {
const result = await runGetApp({}, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: '', data: result.data }))
const lines = out.split('\n').filter(Boolean)
// 第一行为表头
expect(lines[0]).toMatch(/NAME\s+ID\s+MODE/)
})
it('table 输出列顺序正确NAME ID MODE TAGS UPDATED[P0]', async () => {
const result = await runGetApp({}, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: '', data: result.data }))
const header = out.split('\n')[0] ?? ''
const nameIdx = header.indexOf('NAME')
const idIdx = header.indexOf('ID')
const modeIdx = header.indexOf('MODE')
const tagsIdx = header.indexOf('TAGS')
const updatedIdx = header.indexOf('UPDATED')
expect(nameIdx).toBeLessThan(idIdx)
expect(idIdx).toBeLessThan(modeIdx)
expect(modeIdx).toBeLessThan(tagsIdx)
expect(tagsIdx).toBeLessThan(updatedIdx)
})
it('table 输出数据与字段对齐(同一列数据左对齐)[P1]', async () => {
const result = await runGetApp({}, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: '', data: result.data }))
const lines = out.split('\n').filter(Boolean)
// 至少有表头 + 2 行数据ws-1 有 app-1 和 app-2
expect(lines.length).toBeGreaterThanOrEqual(3)
})
it('table 输出支持多行数据ws-1 有 2 个 app[P0]', async () => {
const result = await runGetApp({}, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: '', data: result.data }))
const lines = out.split('\n').filter(Boolean)
// 表头 + 2 数据行
expect(lines.length).toBe(3)
})
it('table 输出空列表场景稳定(无数据时不崩溃)[P1]', async () => {
// ws-nonexistent 返回空列表
const result = await runGetApp({ workspace: 'ws-nonexistent' }, { bundle: baseBundle, http: http() })
expect(() => stringifyOutput(table({ format: '', data: result.data }))).not.toThrow()
expect(result.data.rows).toHaveLength(0)
})
it('table header 大小写正确(全大写)[P1]', async () => {
const result = await runGetApp({}, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: '', data: result.data }))
const header = out.split('\n')[0] ?? ''
expect(header).toMatch(/^NAME\s/)
expect(header).not.toMatch(/name/)
})
// ── wide 格式 ─────────────────────────────────────────────────────────────
it('-o wide 输出包含 AUTHOR 和 WORKSPACE 扩展列 [P1]', async () => {
const result = await runGetApp({ format: 'wide' }, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: 'wide', data: result.data }))
expect(out).toMatch(/AUTHOR\s+WORKSPACE/)
expect(out).toContain('tester')
expect(out).toContain('Default')
})
it('-o wide 的 WORKSPACE 列显示工作区名称(非 ID[P1]', async () => {
const result = await runGetApp({ format: 'wide' }, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: 'wide', data: result.data }))
expect(out).toContain('Default')
// ws-1 的 ID 不应出现在 table 行中
const dataLines = out.split('\n').slice(1).filter(Boolean)
for (const line of dataLines)
expect(line).not.toMatch(/\bws-1\b/)
})
// ── ANSI / pipe 行为 ──────────────────────────────────────────────────────
it('非 TTY 环境bufferStreams isOutTTY=false下 table 无 ANSI 颜色 [P0]', async () => {
// stringifyOutput 不注入 ANSI颜色只在 io 层spinner/color注入
const result = await runGetApp({}, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: '', data: result.data }))
expect(hasAnsi(out)).toBe(false)
})
it('table 输出支持 pipe末尾为 \\n无控制字符[P0]', async () => {
const result = await runGetApp({}, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: '', data: result.data }))
expect(out.endsWith('\n')).toBe(true)
expect(hasAnsi(out)).toBe(false)
})
it('table 输出无额外控制字符(\\r 等)[P0]', async () => {
const result = await runGetApp({}, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: '', data: result.data }))
expect(out).not.toContain('\r')
})
it('table 输出顺序稳定:连续两次执行结果相同 [P1]', async () => {
const render = () =>
runGetApp({}, { bundle: baseBundle, http: http() }).then(r =>
stringifyOutput(table({ format: '', data: r.data })),
)
const out1 = await render()
const out2 = await render()
expect(out1).toBe(out2)
})
// ── 多 workspace 场景 ─────────────────────────────────────────────────────
it('多 workspace table 输出包含 WORKSPACE 列(-A -o wide[P0]', async () => {
const result = await runGetApp({ allWorkspaces: true, format: 'wide' }, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: 'wide', data: result.data }))
expect(out).toContain('WORKSPACE')
expect(out).toContain('Default')
expect(out).toContain('Other')
})
it('WORKSPACE 列显示 workspace 标识(名称非 ID[P1]', async () => {
const result = await runGetApp({ allWorkspaces: true, format: 'wide' }, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: 'wide', data: result.data }))
// ws-2 的数据应显示 "Other" 而非 "ws-2"
const dataLines = out.split('\n').slice(1).filter(Boolean)
const hasWorkspaceName = dataLines.some(l => l.includes('Default') || l.includes('Other'))
expect(hasWorkspaceName).toBe(true)
})
// ── streaming / describe 不使用 table printer ─────────────────────────────
it('streaming 模式不使用 table printerstdout 输出为纯文本 [P0]', async () => {
const c = await cache()
const io = bufferStreams()
await runApp(
{ appId: 'app-1', message: 'hi', stream: true },
{ bundle: baseBundle, http: http(), host: mock.url, io, cache: c },
)
// 不含表头NAME、ID 等 table 列名)
expect(io.outBuf()).not.toMatch(/^NAME\s/m)
})
it('describe 命令不使用 table printer输出为 key: value 分节格式 [P1]', async () => {
const c = await cache()
const data = await runDescribeApp(
{ appId: 'app-1' },
{ bundle: baseBundle, http: http(), host: mock.url, cache: c },
)
const out = stringifyOutput(formatted({ format: '', data }))
// describe 输出含 "Name: Greeter" 风格,不含 table 列名 ID
expect(out).toMatch(/Name:\s+Greeter/)
expect(out).not.toMatch(/^NAME\s/m)
})
// ── JSON/YAML 模式不走 table printer ──────────────────────────────────────
it('JSON 模式不会走 table printer输出为 {…} 而非对齐表格 [P0]', async () => {
const result = await runGetApp({ format: 'json' }, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: 'json', data: result.data }))
// JSON 模式:首字符为 {,不含表头
expect(out.trim().startsWith('{')).toBe(true)
expect(out).not.toMatch(/^NAME\s/m)
})
it('YAML 模式不会走 table printer输出含 data: 而非对齐表格 [P0]', async () => {
const result = await runGetApp({ format: 'yaml' }, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: 'yaml', data: result.data }))
expect(out).toContain('data:')
expect(out).not.toMatch(/^NAME\s/m)
})
// ── 空字段处理 ────────────────────────────────────────────────────────────
it('空字段(无 tags 的 app在 table 中显示为空字符串而非 undefined [P1]', async () => {
const result = await runGetApp({ mode: 'workflow' }, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: '', data: result.data }))
// app-2 没有 tags对应列应为空而非出现 "undefined"
expect(out).not.toContain('undefined')
})
it('NULL 字段显示稳定(不崩溃,不输出 null 字面量)[P1]', async () => {
const result = await runGetApp({}, { bundle: baseBundle, http: http() })
const out = stringifyOutput(table({ format: '', data: result.data }))
expect(out).not.toContain('null')
expect(() => out).not.toThrow()
})
// ── 非法 format ───────────────────────────────────────────────────────────
it('非法 table format 返回 "not supported" 错误 [P0](由 output.ts 统一抛出)', async () => {
const result = await runGetApp({}, { bundle: baseBundle, http: http() })
expect(() =>
stringifyOutput(table({ format: 'csv', data: result.data })),
).toThrow(/not supported/)
})
})