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

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

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

Output:
- JSON/YAML output
- Table output

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

CLI Framework:
- Global Flags
- Non-Interactive mode

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

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

355 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* Dify CLI/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')
})
})