dify/cli/test/e2e/suites/output/table-output.e2e.ts

237 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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.15.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, SOUS, 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)
})
})