test(cli): add integration test suite for Discovery, Run, Output, Error Handling and CLI Framework

Add comprehensive integration tests under cli/test/testcases/ covering:

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

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

Output:
- JSON/YAML output
- Table output

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

CLI Framework:
- Global Flags
- Non-Interactive mode

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

Known bugs tracked as it.todo:
- WTA-249: server 4xx in -o json mode exit code should be 1 (currently 0 in some cases)
- WTA-252: --help missing GLOBAL FLAGS section and Quick start examples
- WTA-255: hosts.yml YAML parse failure should output JSON envelope
- WTA-257: uncaught TypeError should output JSON envelope in -o json mode
This commit is contained in:
gigglewang
2026-05-22 10:46:18 +08:00
parent 5381452de9
commit c38c5d375e
24 changed files with 5190 additions and 51 deletions

View File

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

View File

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