mirror of
https://github.com/langgenius/dify.git
synced 2026-05-26 03:47:42 +08:00
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:
@ -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 })
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
32
cli/test/fixtures/dify-mock/scenarios.ts
vendored
32
cli/test/fixtures/dify-mock/scenarios.ts
vendored
@ -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',
|
||||
|
||||
10
cli/test/fixtures/dify-mock/server.test.ts
vendored
10
cli/test/fixtures/dify-mock/server.test.ts
vendored
@ -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()
|
||||
})
|
||||
|
||||
|
||||
57
cli/test/fixtures/dify-mock/server.ts
vendored
57
cli/test/fixtures/dify-mock/server.ts
vendored
@ -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())
|
||||
|
||||
354
cli/test/testcases/cli-framework/global-flags.test.ts
Normal file
354
cli/test/testcases/cli-framework/global-flags.test.ts
Normal file
@ -0,0 +1,354 @@
|
||||
/**
|
||||
* Dify CLI/CLI Framework/Global Flags 集成测试
|
||||
*
|
||||
* 用例来源:飞书文档《Dify CLI Enhanced》— Dify CLI/CLI Framework/Global Flags(33 条)
|
||||
*
|
||||
* 覆盖策略:
|
||||
* - 通过 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 覆盖默认 workspace(get 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('resolveRetryAttempts:flag 优先于 env 变量 [P0]', () => {
|
||||
expect(resolveRetryAttempts({ flag: 0, env: () => '5' })).toBe(0)
|
||||
})
|
||||
|
||||
it('resolveRetryAttempts:env 变量为 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() 返回 2(Usage)[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')
|
||||
})
|
||||
})
|
||||
260
cli/test/testcases/cli-framework/non-interactive.test.ts
Normal file
260
cli/test/testcases/cli-framework/non-interactive.test.ts
Normal file
@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Dify CLI/CLI Framework/Non-Interactive 集成测试
|
||||
*
|
||||
* 用例来源:飞书文档《Dify CLI Enhanced》— Dify CLI/CLI Framework/Non-Interactive(30 条)
|
||||
*
|
||||
* 覆盖策略:
|
||||
* - 通过 bufferStreams(isOutTTY=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=false)colorEnabled 返回 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=false)spinner 不输出到 stderr [P0]', async () => {
|
||||
// bufferStreams() 默认 isErrTTY=false → spinner 被禁用(NOOP_SPINNER)
|
||||
const io = bufferStreams()
|
||||
await runGetApp({}, { bundle: baseBundle, http: http(), io })
|
||||
// 如果有 spinner,errBuf 会包含 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 日志不污染 stdout(bufferStreams 分离)[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('非交互模式错误立即返回(不 hang),stderr 有错误信息 [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)
|
||||
})
|
||||
})
|
||||
273
cli/test/testcases/commands/describe/app/describe.test.ts
Normal file
273
cli/test/testcases/commands/describe/app/describe.test.ts
Normal file
@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Discovery / Describe App 集成测试
|
||||
*
|
||||
* 用例来源:飞书文档《Dify CLI Enhanced》— Dify CLI/Discovery/Describe App(29 条)
|
||||
* 命令: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 endpoint(GET /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 输出包含 Description(app 有 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 输出包含 Inputs(Parameters)分节 [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 为 1(Generic)[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 为 4(Auth)[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()
|
||||
})
|
||||
})
|
||||
227
cli/test/testcases/commands/get/app/all-workspaces.test.ts
Normal file
227
cli/test/testcases/commands/get/app/all-workspaces.test.ts
Normal 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_workspaces:total 为所有 workspace app 之和 [P0]', async () => {
|
||||
const result = await runGetApp({ allWorkspaces: true }, { bundle: baseBundle, http: http() })
|
||||
// ws-1 有 2 个 app,ws-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 为 4(Auth)[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)
|
||||
}
|
||||
})
|
||||
})
|
||||
452
cli/test/testcases/commands/get/app/list.test.ts
Normal file
452
cli/test/testcases/commands/get/app/list.test.ts
Normal 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 个 App,ws-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-limited(429)时抛出包含限流信息的错误', async () => {
|
||||
mock.setScenario('rate-limited')
|
||||
await expect(
|
||||
runGetApp({}, { bundle: baseBundle, http: http() }),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('TC-ERR-002: 服务端 5xx(503)时抛出错误', async () => {
|
||||
mock.setScenario('server-5xx')
|
||||
await expect(
|
||||
runGetApp({}, { bundle: baseBundle, http: http() }),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('TC-ERR-003: token 过期(auth-expired,401)时抛出错误', async () => {
|
||||
mock.setScenario('auth-expired')
|
||||
await expect(
|
||||
runGetApp({}, { bundle: baseBundle, http: http() }),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
222
cli/test/testcases/commands/get/app/single.test.ts
Normal file
222
cli/test/testcases/commands/get/app/single.test.ts
Normal 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 endpoint(describe + 封装为 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 为 1(Generic)[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 为 4(Auth)[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()
|
||||
})
|
||||
})
|
||||
300
cli/test/testcases/commands/run/app/basic.test.ts
Normal file
300
cli/test/testcases/commands/run/app/basic.test.ts
Normal 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 app,stdout 输出结果 [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 endpoint(app-2 workflow,stdout 有输出)[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 支持 --inputs(workflow)[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 为 1(Generic)[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 为 4(Auth)[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}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
135
cli/test/testcases/commands/run/app/cache-consistency.test.ts
Normal file
135
cli/test/testcases/commands/run/app/cache-consistency.test.ts
Normal 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 默认为 1h(3600000ms)[P1]', () => {
|
||||
expect(APP_INFO_TTL_MS).toBe(60 * 60 * 1000)
|
||||
})
|
||||
|
||||
it('1h 内 run app 使用缓存的 mode(isFresh=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 () => {
|
||||
// 使用极短 TTL(1ms),使缓存立即过期
|
||||
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,应重新 fetch(mock 服务器被调用 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)
|
||||
})
|
||||
})
|
||||
229
cli/test/testcases/commands/run/app/conversation.test.ts
Normal file
229
cli/test/testcases/commands/run/app/conversation.test.ts
Normal 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 可创建新 conversation,stderr 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 envelope(BaseError)[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)
|
||||
})
|
||||
})
|
||||
233
cli/test/testcases/commands/run/app/env-inject.test.ts
Normal file
233
cli/test/testcases/commands/run/app/env-inject.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
268
cli/test/testcases/commands/run/app/file-input.test.ts
Normal file
268
cli/test/testcases/commands/run/app/file-input.test.ts
Normal 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=@path),upload 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 API(lastRunBody 含 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)
|
||||
})
|
||||
})
|
||||
340
cli/test/testcases/commands/run/app/hitl.test.ts
Normal file
340
cli/test/testcases/commands/run/app/hitl.test.ts
Normal 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 block,exit 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=false,hint 应提示 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 应为 1(Generic)
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
244
cli/test/testcases/commands/run/app/streaming.test.ts
Normal file
244
cli/test/testcases/commands/run/app/streaming.test.ts
Normal 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 不混入 stdout(stdout 仅含答案)[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 模式支持多 input(workflow 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> block:stdout 不包含思考内容 [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 event,CLI 抛出 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 envelope(BaseError)[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 run(dfoe_ 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:')
|
||||
})
|
||||
})
|
||||
527
cli/test/testcases/error-handling/error-message.test.ts
Normal file
527
cli/test/testcases/error-handling/error-message.test.ts
Normal 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_flag,message 明确描述原因 [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_flag,message 含 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_arg,message 含 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_flag,hint 建议用 --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_flag,message 含 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_flag,message 含 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 error(auth-expired)code=auth_expired,message 不为空 [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_5xx,message 不为空 [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 trace(message 不含 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_other,httpStatus 为 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 token(redactBearer 已应用)[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=false)humanError 输出不含 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 trace(humanError 无 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 为 1(Generic)[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')
|
||||
})
|
||||
})
|
||||
322
cli/test/testcases/error-handling/exit-code.test.ts
Normal file
322
cli/test/testcases/error-handling/exit-code.test.ts
Normal file
@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Dify CLI/Error Handling/Exit Code 集成测试
|
||||
*
|
||||
* 用例来源:飞书文档《Dify CLI Enhanced》— Dify CLI/Error Handling/Exit Code(29 条)
|
||||
*
|
||||
* 测试策略:
|
||||
* - 通过 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 为 0(get app 正常返回)[P0]', async () => {
|
||||
const code = await captureExit(() => runGetApp({}, { bundle: baseBundle, http: http() }))
|
||||
expect(code).toBe(ExitCode.Success)
|
||||
})
|
||||
|
||||
it('成功命令 exit code 为 0(describe 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 为 0(run 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 error(auth-expired)exit code 为 4 [P0]', async () => {
|
||||
mock.setScenario('auth-expired')
|
||||
const code = await captureExit(() => runGetApp({}, { bundle: baseBundle, http: http() }))
|
||||
expect(code).toBe(ExitCode.Auth)
|
||||
})
|
||||
|
||||
it('authentication error(run app auth-expired)exit 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 error(4 ≠ 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 为 1(Generic)[P0]', async () => {
|
||||
const code = await captureExit(() =>
|
||||
runGetApp({ appId: 'app-nonexistent' }, { bundle: baseBundle, http: http() }),
|
||||
)
|
||||
expect(code).toBe(ExitCode.Generic)
|
||||
})
|
||||
|
||||
it('server 500 exit code 为 1(Generic)[P0]', async () => {
|
||||
mock.setScenario('server-5xx')
|
||||
const code = await captureExit(() => runGetApp({}, { bundle: baseBundle, http: http() }))
|
||||
expect(code).toBe(ExitCode.Generic)
|
||||
})
|
||||
|
||||
it('network error(rate-limited 429)exit code 为 1(Generic)[P0]', async () => {
|
||||
mock.setScenario('rate-limited')
|
||||
const code = await captureExit(() => runGetApp({}, { bundle: baseBundle, http: http() }))
|
||||
expect(code).toBe(ExitCode.Generic)
|
||||
})
|
||||
|
||||
it('upload failed(server-5xx 场景)exit code 为 1(Generic)[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 为 2(Usage)[P0]', async () => {
|
||||
const code = await captureExit(() =>
|
||||
runGetApp({ limitRaw: '999' }, { bundle: baseBundle, http: http() }),
|
||||
)
|
||||
expect(code).toBe(ExitCode.Usage)
|
||||
})
|
||||
|
||||
it('参数错误(--limit 非数字)exit code 为 2(Usage)[P0]', async () => {
|
||||
const code = await captureExit(() =>
|
||||
runGetApp({ limitRaw: 'abc' }, { bundle: baseBundle, http: http() }),
|
||||
)
|
||||
expect(code).toBe(ExitCode.Usage)
|
||||
})
|
||||
|
||||
it('参数错误(--inputs 非法 JSON)exit code 为 2(Usage)[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 为 2(Usage)[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 为 2(Usage)[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 为 2(UsageMissingArg)[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 为 2(Usage)[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 仍为 1(Generic)[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 4xx(app 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)
|
||||
})
|
||||
})
|
||||
331
cli/test/testcases/output/json-yaml.test.ts
Normal file
331
cli/test/testcases/output/json-yaml.test.ts
Normal 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 输出合法 JSON(get 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 输出合法 JSON(chat 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 输出合法 YAML(get 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('错误场景:抛出 BaseError,code 和 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
263
cli/test/testcases/output/table.test.ts
Normal file
263
cli/test/testcases/output/table.test.ts
Normal 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('默认输出格式为 table(get 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 printer:stdout 输出为纯文本 [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/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user