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

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

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

Output:
- JSON/YAML output
- Table output

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

CLI Framework:
- Global Flags
- Non-Interactive mode

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

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

204 lines
5.4 KiB
TypeScript

import yaml from 'js-yaml'
export type RawOutput = {
readonly kind: 'raw'
readonly data: string
}
export type TableCell = string | number | boolean | null | undefined
export type TableColumn = {
readonly name: string
readonly priority: number
}
export type TablePrintable = {
readonly tableColumns: () => readonly TableColumn[]
readonly tableRows: () => readonly (readonly TableCell[])[]
readonly json: () => unknown
}
export type FormattedPrintable = {
readonly text: () => string
readonly json: () => unknown
}
export type NamePrintable = {
readonly name: () => string
}
export type JsonPrintable = {
readonly json: () => unknown
}
export type TableOutput<TRow extends TablePrintable> = {
readonly kind: 'table'
readonly format: string
readonly data: TRow
}
export type FormattedOutput<TData extends FormattedPrintable> = {
readonly kind: 'formatted'
readonly format: string
readonly data: TData
}
export type CommandOutput = RawOutput | TableOutput<TablePrintable> | FormattedOutput<FormattedPrintable>
export function raw(data: string): RawOutput {
return { kind: 'raw', data }
}
export function table<TRow extends TablePrintable>(opts: {
readonly format: string
readonly data: TRow
}): TableOutput<TRow> {
return { kind: 'table', ...opts }
}
export function formatted<TData extends FormattedPrintable>(opts: {
readonly format: string
readonly data: TData
}): FormattedOutput<TData> {
return { kind: 'formatted', ...opts }
}
export function stringifyOutput(output: CommandOutput): string {
switch (output.kind) {
case 'raw':
return output.data
case 'table':
return stringifyTableOutput(output)
case 'formatted':
return stringifyFormattedOutput(output)
}
}
function stringifyFormattedOutput(output: FormattedOutput<FormattedPrintable>): string {
switch (output.format) {
case '':
case 'text':
return output.data.text()
default:
return stringifyJsonLike(output.format, output.data, 'json, name, text, yaml')
}
}
function stringifyTableOutput(output: TableOutput<TablePrintable>): string {
switch (output.format) {
case '':
case 'wide':
return renderTable(output)
default:
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}`)
}
}
function renderTable(output: TableOutput<TablePrintable>): string {
const wide = output.format === 'wide'
const columns = output.data.tableColumns()
const keep: number[] = []
for (let i = 0; i < columns.length; i++) {
const column = columns[i]
if (column !== undefined && (column.priority === 0 || wide))
keep.push(i)
}
const rows = [
keep.map(i => columns[i]?.name ?? ''),
...output.data.tableRows().map(row => keep.map((idx) => {
const cell = row[idx]
return cell === null || cell === undefined ? '' : String(cell)
})),
]
return formatTable(rows)
}
function isWideCodePoint(cp: number): boolean {
return (
(cp >= 0x1100 && cp <= 0x115F)
|| cp === 0x2329 || cp === 0x232A
|| (cp >= 0x2E80 && cp <= 0x3247)
|| (cp >= 0x3250 && cp <= 0x4DBF)
|| (cp >= 0x4E00 && cp <= 0xA4C6)
|| (cp >= 0xA960 && cp <= 0xA97C)
|| (cp >= 0xAC00 && cp <= 0xD7A3)
|| (cp >= 0xF900 && cp <= 0xFAFF)
|| (cp >= 0xFE10 && cp <= 0xFE19)
|| (cp >= 0xFE30 && cp <= 0xFE6B)
|| (cp >= 0xFF01 && cp <= 0xFF60)
|| (cp >= 0xFFE0 && cp <= 0xFFE6)
|| (cp >= 0x1B000 && cp <= 0x1B001)
|| (cp >= 0x1F200 && cp <= 0x1F251)
|| (cp >= 0x20000 && cp <= 0x3FFFD)
)
}
function displayWidth(s: string): number {
let w = 0
for (const ch of s)
w += isWideCodePoint(ch.codePointAt(0) ?? 0) ? 2 : 1
return w
}
function formatTable(rows: readonly (readonly string[])[]): string {
if (rows.length === 0)
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 w = rw[i] ?? 0
if (w > (widths[i] ?? 0))
widths[i] = w
}
}
const lines = rows.map((row, rowIdx) => {
const rw = rowWidths[rowIdx] ?? []
const cells: string[] = []
for (let i = 0; i < colCount; i++) {
const cell = row[i] ?? ''
const isLast = i === colCount - 1
if (isLast) {
cells.push(cell)
}
else {
const pad = (widths[i] ?? 0) - (rw[i] ?? 0) + 2
cells.push(cell + ' '.repeat(pad))
}
}
return cells.join('')
})
return `${lines.join('\n')}\n`
}
function toName(data: TablePrintable | FormattedPrintable): string {
if (!isNamePrintable(data))
throw new Error('name output requires data.name()')
return data.name()
}
function isNamePrintable(data: TablePrintable | FormattedPrintable): data is (TablePrintable | FormattedPrintable) & NamePrintable {
return typeof (data as { name?: unknown }).name === 'function'
}