dify/cli/test/e2e/suites/dsl/export-app.e2e.ts
Yunlu Wen 0a051b598f
feat: support import / export dsl in CLI (#37232)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: cheatofrom <85830867+cheatofrom@users.noreply.github.com>
Co-authored-by: Escape0707 <tothesong@gmail.com>
Co-authored-by: Rohit Gahlawat <personal.rg56@gmail.com>
Co-authored-by: L1nSn0w <l1nsn0w@qq.com>
Co-authored-by: 盐粒 Yanli <yanli@dify.ai>
2026-06-10 09:51:40 +00:00

197 lines
7.3 KiB
TypeScript

/**
* E2E: difyctl export app — DSL export
*
* Prerequisites (DIFY_E2E_* env vars):
* DIFY_E2E_WORKFLOW_APP_ID — echo-workflow app (no model provider dependency)
* DIFY_E2E_CHAT_APP_ID — echo-chat app
*/
import type { AuthFixture } from '../../helpers/cli.js'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
import {
assertExitCode,
} from '../../helpers/assert.js'
import { run, withAuthFixture, withTempConfig } from '../../helpers/cli.js'
import { 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)
describe('E2E / difyctl export app', () => {
let fx: AuthFixture
beforeEach(async () => {
fx = await withAuthFixture(E)
})
afterEach(async () => {
await fx.cleanup()
})
// ── Basic export ──────────────────────────────────────────────────────────
it('[P0] exported DSL is non-empty YAML printed to stdout', async () => {
const result = await fx.r(['export', 'app', E.workflowAppId])
assertExitCode(result, 0)
expect(result.stdout.trim().length).toBeGreaterThan(0)
})
it('[P0] exported YAML contains kind: app', async () => {
const result = await fx.r(['export', 'app', E.workflowAppId])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/^kind:\s*app/m)
})
it('[P0] exported YAML contains version field', async () => {
const result = await fx.r(['export', 'app', E.workflowAppId])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/^version:/m)
})
it('[P0] exported YAML contains app section with mode', async () => {
const result = await fx.r(['export', 'app', E.workflowAppId])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/^\s+mode:/m)
})
it('[P1] exported YAML ends with a newline (POSIX pipe convention)', async () => {
const result = await fx.r(['export', 'app', E.workflowAppId])
assertExitCode(result, 0)
expect(result.stdout.endsWith('\n')).toBe(true)
})
it('[P1] chat app export also succeeds and includes mode', async () => {
const result = await fx.r(['export', 'app', E.chatAppId])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/^kind:\s*app/m)
expect(result.stdout).toMatch(/^\s+mode:/m)
})
// ── --output flag ─────────────────────────────────────────────────────────
it('[P1] --output writes DSL to file and exits 0', async () => {
const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-export-'))
const outPath = join(dir, 'exported.yaml')
try {
const result = await fx.r(['export', 'app', E.workflowAppId, '--output', outPath])
assertExitCode(result, 0)
const content = await readFile(outPath, 'utf8')
expect(content).toMatch(/^kind:\s*app/m)
expect(content).toMatch(/^version:/m)
}
finally {
await rm(dir, { recursive: true, force: true })
}
})
it('[P1] --output writes same content as stdout', async () => {
const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-export-cmp-'))
const outPath = join(dir, 'exported.yaml')
try {
const [stdoutResult, fileResult] = await Promise.all([
fx.r(['export', 'app', E.workflowAppId]),
fx.r(['export', 'app', E.workflowAppId, '--output', outPath]).then(async (r) => {
const content = await readFile(outPath, 'utf8')
return { exitCode: r.exitCode, content }
}),
])
assertExitCode(stdoutResult, 0)
expect(fileResult.exitCode).toBe(0)
expect(fileResult.content.trim()).toBe(stdoutResult.stdout.trim())
}
finally {
await rm(dir, { recursive: true, force: true })
}
})
// ── Roundtrip: export → import ────────────────────────────────────────────
it('[P1] roundtrip: exported DSL can be re-imported as a new app', async () => {
const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-roundtrip-'))
const dslPath = join(dir, 'roundtrip.yaml')
try {
const exportResult = await fx.r(['export', 'app', E.workflowAppId, '--output', dslPath])
assertExitCode(exportResult, 0)
const importResult = await fx.r([
'import',
'app',
'--from-file',
dslPath,
'--name',
'e2e-export-roundtrip',
])
assertExitCode(importResult, 0)
const match = importResult.stderr.match(/app ([0-9a-f-]{36})/)
expect(match?.[1], 'import stderr must contain the new app UUID').toBeTruthy()
}
finally {
await rm(dir, { recursive: true, force: true })
}
})
// ── Error scenarios ───────────────────────────────────────────────────────
it('[P0] non-existent app returns exit code 1 with error in stderr', async () => {
const result = await fx.r(['export', 'app', 'nonexistent-app-id-export-e2e'])
expect(result.exitCode).toBe(1)
expect(result.stderr.length).toBeGreaterThan(0)
})
it('[P0] unauthenticated export returns auth error (exit code 4)', async () => {
const unauthTmp = await withTempConfig()
try {
const result = await run(['export', 'app', E.workflowAppId], {
configDir: unauthTmp.configDir,
})
assertExitCode(result, 4)
}
finally {
await unauthTmp.cleanup()
}
})
it('[P1] export with missing app id argument exits non-zero', async () => {
const result = await fx.r(['export', 'app'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/missing required argument|required|app id/i)
})
it('[P1] malformed --workflow-id returns a 4xx, not a 5xx', async () => {
const result = await fx.r(['export', 'app', E.workflowAppId, '--workflow-id', 'not-a-uuid'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/http_status:\s*4\d\d/)
expect(result.stderr).not.toMatch(/http_status:\s*5\d\d/)
})
it('[P1] non-existent --workflow-id returns 404, not a 5xx', async () => {
const result = await fx.r([
'export',
'app',
E.workflowAppId,
'--workflow-id',
'00000000-0000-0000-0000-000000000000',
])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/http_status:\s*404/)
})
it('[P1] non-existent app in --output mode leaves no file behind', async () => {
const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-export-nofile-'))
const outPath = join(dir, 'should-not-exist.yaml')
try {
const result = await fx.r(['export', 'app', 'nonexistent-app-id-nofile-e2e', '--output', outPath])
expect(result.exitCode).not.toBe(0)
const exists = await readFile(outPath, 'utf8').then(() => true).catch(() => false)
expect(exists, 'output file must not be created on export failure').toBe(false)
}
finally {
await rm(dir, { recursive: true, force: true })
}
})
})