mirror of
https://github.com/langgenius/dify.git
synced 2026-05-30 13:47:52 +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:
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user