mirror of
https://github.com/langgenius/dify.git
synced 2026-06-13 04:01:12 +08:00
237 lines
11 KiB
TypeScript
237 lines
11 KiB
TypeScript
/**
|
||
* E2E: Table output format — spec 5.1
|
||
*
|
||
* Covers the default text-table output behaviour of query commands.
|
||
* The default format (no -o flag) is an aligned text table; -o table does not
|
||
* exist and returns an illegal_argument error.
|
||
*
|
||
* Primary command under test: difyctl get app
|
||
* Additional commands: difyctl get workspace, difyctl auth devices list
|
||
*
|
||
* Non-automatable cases (excluded):
|
||
* 5.4 Row/column alignment — requires visual inspection, no reliable
|
||
* programmatic assertion.
|
||
* 5.7 Long-text truncation based on terminal width — terminal width is
|
||
* not controllable in E2E.
|
||
* 5.8 Very long text still readable — same reason as 5.7, and test data
|
||
* cannot be controlled.
|
||
* 5.9 CJK/emoji alignment — CJK column-width alignment requires visual
|
||
* inspection; current fixtures have no CJK app names.
|
||
* 5.10 CJK column width — same as 5.9.
|
||
* 5.11 Small terminal width — terminal width not controllable.
|
||
* 5.12 Large terminal width — same as 5.11.
|
||
* 5.13 ANSI colour in TTY — E2E runs with NO_COLOR=1 and CI=1 (non-TTY).
|
||
* 5.18 NULL fields stable — current fixtures have no NULL field values.
|
||
* 5.21 run app --stream non-table — covered by run-app-streaming.e2e.ts.
|
||
* 5.22 describe app uses describe printer — covered by describe-app.e2e.ts.
|
||
* 5.23 Printer error / fallback — cannot reliably trigger a printer error.
|
||
* 5.24 Printer error exit code — same as 5.23.
|
||
* 5.20 get app -A -o wide has WORKSPACE column — covered by
|
||
* get-app-all-workspaces.e2e.ts (spec 3.92/3.93).
|
||
*
|
||
* Already covered in get-app-list.e2e.ts (not duplicated here):
|
||
* 5.1 (partial) default format is not JSON
|
||
* 5.2 (partial) header contains ID / NAME / MODE
|
||
* 5.14 no ANSI colour codes in non-TTY
|
||
*
|
||
* All cases require a valid session (DIFY_E2E_TOKEN).
|
||
*/
|
||
|
||
import type { AuthFixture } from '../../helpers/cli.js'
|
||
import { afterEach, beforeEach, describe, expect, it, inject } from 'vitest'
|
||
import { assertExitCode, assertNoAnsi } from '../../helpers/assert.js'
|
||
import { withAuthFixture } from '../../helpers/cli.js'
|
||
import { loadE2EEnv, resolveEnv } from '../../setup/env.js'
|
||
|
||
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
|
||
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
|
||
const E = resolveEnv(caps)
|
||
|
||
// ── 5.1 / 5.2 / 5.3 / 5.5 / 5.19 — Header & columns ───────────────────────
|
||
describe('E2E / table output — header and column format (spec 5.1–5.19)', () => {
|
||
let fx: AuthFixture
|
||
|
||
beforeEach(async () => { fx = await withAuthFixture(E) })
|
||
afterEach(async () => { await fx.cleanup() })
|
||
|
||
it('[P0] 5.1 default output (no -o) is an aligned text table, not JSON or YAML', async () => {
|
||
// Spec 5.1: the default format is a text table; -o table does not exist.
|
||
const result = await fx.r(['get', 'app'])
|
||
assertExitCode(result, 0)
|
||
// Must not be JSON (starts with {) or YAML (starts with -)
|
||
expect(result.stdout.trimStart()).not.toMatch(/^\{/)
|
||
expect(result.stdout.trimStart()).not.toMatch(/^- /)
|
||
// Must have content (non-empty)
|
||
expect(result.stdout.trim().length).toBeGreaterThan(0)
|
||
})
|
||
|
||
it('[P0] 5.2 header row contains all five expected column names', async () => {
|
||
// Spec 5.2: header columns are NAME / ID / MODE / TAGS / UPDATED.
|
||
const result = await fx.r(['get', 'app'])
|
||
assertExitCode(result, 0)
|
||
const header = result.stdout.split('\n')[0] ?? ''
|
||
expect(header).toMatch(/NAME/i)
|
||
expect(header).toMatch(/ID/i)
|
||
expect(header).toMatch(/MODE/i)
|
||
expect(header).toMatch(/TAGS/i)
|
||
expect(header).toMatch(/UPDATED/i)
|
||
})
|
||
|
||
it('[P0] 5.3 column order is NAME → ID → MODE → TAGS → UPDATED', async () => {
|
||
// Spec 5.3: columns appear in the defined order (as verified from actual CLI output).
|
||
const result = await fx.r(['get', 'app'])
|
||
assertExitCode(result, 0)
|
||
const header = result.stdout.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')
|
||
// All columns must be present
|
||
expect(nameIdx).toBeGreaterThanOrEqual(0)
|
||
expect(idIdx).toBeGreaterThanOrEqual(0)
|
||
expect(modeIdx).toBeGreaterThanOrEqual(0)
|
||
expect(tagsIdx).toBeGreaterThanOrEqual(0)
|
||
expect(updatedIdx).toBeGreaterThanOrEqual(0)
|
||
// Verify left-to-right order
|
||
expect(nameIdx).toBeLessThan(idIdx)
|
||
expect(idIdx).toBeLessThan(modeIdx)
|
||
expect(modeIdx).toBeLessThan(tagsIdx)
|
||
expect(tagsIdx).toBeLessThan(updatedIdx)
|
||
})
|
||
|
||
it('[P0] 5.5 table displays multiple data rows when more than one app exists', async () => {
|
||
// Spec 5.5: when there are multiple apps, all rows are rendered.
|
||
const result = await fx.r(['get', 'app'])
|
||
assertExitCode(result, 0)
|
||
const lines = result.stdout.trim().split('\n').filter(l => l.trim())
|
||
// At least header + 1 data row
|
||
expect(lines.length).toBeGreaterThan(1)
|
||
})
|
||
|
||
it('[P0] 5.6 empty result set shows only the header row (no data rows)', async () => {
|
||
// Spec 5.6: when the filter matches nothing, the output is a single header
|
||
// row with no data rows underneath (not an error, exit 0).
|
||
const result = await fx.r(['get', 'app', '--name', 'zzz-nonexistent-app-xyz-000'])
|
||
assertExitCode(result, 0)
|
||
const lines = result.stdout.trim().split('\n').filter(l => l.trim())
|
||
// Only the header row should remain
|
||
expect(lines).toHaveLength(1)
|
||
expect(lines[0] ?? '').toMatch(/NAME/i)
|
||
})
|
||
|
||
it('[P0] 5.19 all header column names are uppercase', async () => {
|
||
// Spec 5.19: header column names follow all-caps convention per implementation.
|
||
const result = await fx.r(['get', 'app'])
|
||
assertExitCode(result, 0)
|
||
const header = result.stdout.split('\n')[0] ?? ''
|
||
// Extract word-like tokens from the header
|
||
const tokens = header.match(/[A-Z]{2,}/g) ?? []
|
||
expect(tokens.length).toBeGreaterThan(0)
|
||
tokens.forEach(token =>
|
||
expect(token, `header token "${token}" should be uppercase`).toBe(token.toUpperCase()),
|
||
)
|
||
})
|
||
|
||
// ── 5.15 / 5.16 — Pipe-friendliness ──────────────────────────────────────
|
||
|
||
it('[P0] 5.15 default table output is pipe-friendly — no unexpected control characters', async () => {
|
||
// Spec 5.15: output can pass through cat / pipes without corruption.
|
||
const result = await fx.r(['get', 'app'])
|
||
assertExitCode(result, 0)
|
||
// No NUL, BEL, BS, VT, FF, SO–US, DEL bytes that would corrupt a pipe
|
||
// eslint-disable-next-line no-control-regex
|
||
expect(result.stdout).not.toMatch(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/)
|
||
})
|
||
|
||
it('[P0] 5.16 default table output written to a file contains no control characters', async () => {
|
||
// Spec 5.16: redirecting to a file must not embed control characters.
|
||
const result = await fx.r(['get', 'app'])
|
||
assertExitCode(result, 0)
|
||
assertNoAnsi(result.stdout, 'stdout')
|
||
// eslint-disable-next-line no-control-regex
|
||
expect(result.stdout).not.toMatch(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/)
|
||
})
|
||
|
||
// ── 5.17 — Empty-field rendering ─────────────────────────────────────────
|
||
|
||
it('[P1] 5.17 empty TAGS field is rendered as blank — not as a dash (-)', async () => {
|
||
// Spec 5.17: empty fields show blank, not the `-` placeholder.
|
||
// Most apps in the fixture workspace have no tags.
|
||
const result = await fx.r(['get', 'app'])
|
||
assertExitCode(result, 0)
|
||
const lines = result.stdout.trim().split('\n')
|
||
const header = lines[0] ?? ''
|
||
const tagsStart = header.indexOf('TAGS')
|
||
const updatedStart = header.indexOf('UPDATED')
|
||
// Check at least one data row: the TAGS slice should be blank, not '-'
|
||
const dataLines = lines.slice(1).filter(l => l.trim())
|
||
if (dataLines.length > 0 && tagsStart >= 0 && updatedStart > tagsStart) {
|
||
const tagsSlice = (dataLines[0] ?? '').substring(tagsStart, updatedStart).trim()
|
||
// If there are no tags, the slice should be empty (not contain a lone '-')
|
||
if (tagsSlice === '') {
|
||
expect(tagsSlice).toBe('')
|
||
}
|
||
else {
|
||
// Tags are present — just verify it's not the placeholder dash
|
||
expect(tagsSlice).not.toBe('-')
|
||
}
|
||
}
|
||
})
|
||
|
||
// ── 5.25 — Performance ────────────────────────────────────────────────────
|
||
|
||
it('[P1] 5.25 querying up to 100 apps completes without timeout', async () => {
|
||
// Spec 5.25: large result sets must not freeze the CLI.
|
||
// The testTimeout covers the timeout assertion implicitly.
|
||
const result = await fx.r(['get', 'app', '--limit', '100'])
|
||
assertExitCode(result, 0)
|
||
expect(result.stdout.trim().length).toBeGreaterThan(0)
|
||
})
|
||
|
||
// ── 5.26 — Sort stability ─────────────────────────────────────────────────
|
||
|
||
it('[P1] 5.26 two consecutive get app calls return rows in the same order', async () => {
|
||
// Spec 5.26: output order must be deterministic (updated_at DESC).
|
||
const r1 = await fx.r(['get', 'app', '-o', 'name'])
|
||
const r2 = await fx.r(['get', 'app', '-o', 'name'])
|
||
assertExitCode(r1, 0)
|
||
assertExitCode(r2, 0)
|
||
expect(r1.stdout).toBe(r2.stdout)
|
||
})
|
||
|
||
// ── Additional commands — header format ───────────────────────────────────
|
||
|
||
it('[P0] get workspace default table has correct column headers', async () => {
|
||
// Verifies the header columns for the workspace list table.
|
||
const result = await fx.r(['get', 'workspace'])
|
||
assertExitCode(result, 0)
|
||
const header = result.stdout.split('\n')[0] ?? ''
|
||
expect(header).toMatch(/ID/i)
|
||
expect(header).toMatch(/NAME/i)
|
||
expect(header).toMatch(/ROLE/i)
|
||
expect(header).toMatch(/STATUS/i)
|
||
expect(header).toMatch(/CURRENT/i)
|
||
})
|
||
|
||
it('[P0] auth devices list default table has correct column headers', async () => {
|
||
// Verifies the header columns for the devices list table.
|
||
const result = await fx.r(['auth', 'devices', 'list'])
|
||
assertExitCode(result, 0)
|
||
const header = result.stdout.split('\n')[0] ?? ''
|
||
expect(header).toMatch(/DEVICE/i)
|
||
expect(header).toMatch(/CREATED/i)
|
||
expect(header).toMatch(/CURRENT/i)
|
||
})
|
||
|
||
// ── -o table is not a valid format ────────────────────────────────────────
|
||
|
||
it('[P0] -o table returns illegal_argument error for query commands', async () => {
|
||
// Spec: -o table does not exist; the default (no -o) is the table format.
|
||
const result = await fx.r(['get', 'app', '-o', 'table'])
|
||
expect(result.exitCode).toBe(2)
|
||
expect(result.stderr).toMatch(/illegal_argument|illegal value table/i)
|
||
expect(result.stderr).toMatch(/json|yaml|name|wide/i)
|
||
})
|
||
})
|