Files
dify/cli/scripts/generate-command-tree.test.ts
Yunlu Wen a728e0ac69 feat: adding dify cli (#36348)
Co-authored-by: GareArc <garethcxy@dify.ai>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: L1nSn0w <l1nsn0w@qq.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: gigglewang <gigglewang@dify.ai>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Xiyuan Chen <52963600+GareArc@users.noreply.github.com>
2026-05-26 01:12:36 +00:00

215 lines
8.3 KiB
TypeScript

import type { CommandEntry } from './generate-command-tree.js'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
buildTree,
discoverCommands,
formatModule,
generate,
pathToTokens,
tokensToIdentifier,
} from './generate-command-tree.js'
describe('pathToTokens', () => {
it('extracts tokens for nested command', () => {
expect(pathToTokens('src/commands/auth/devices/list/index.ts', 'src/commands'))
.toEqual(['auth', 'devices', 'list'])
})
it('extracts tokens for top-level command', () => {
expect(pathToTokens('src/commands/version/index.ts', 'src/commands'))
.toEqual(['version'])
})
it('normalizes backslashes (windows-style paths)', () => {
expect(pathToTokens('src\\commands\\auth\\login\\index.ts', 'src/commands'))
.toEqual(['auth', 'login'])
})
})
describe('tokensToIdentifier', () => {
it('pascal-cases joined tokens', () => {
expect(tokensToIdentifier(['auth', 'devices', 'list'])).toBe('AuthDevicesList')
expect(tokensToIdentifier(['version'])).toBe('Version')
expect(tokensToIdentifier(['run', 'app', 'resume'])).toBe('RunAppResume')
})
it('splits hyphenated tokens', () => {
expect(tokensToIdentifier(['agent-chat'])).toBe('AgentChat')
expect(tokensToIdentifier(['my-cmd', 'sub-thing'])).toBe('MyCmdSubThing')
})
it('prefixes underscore on reserved words', () => {
expect(tokensToIdentifier(['delete'])).toBe('_Delete')
})
})
describe('buildTree', () => {
it('assembles a nested tree from entries', () => {
const entries = [
{ tokens: ['auth', 'login'], identifier: 'AuthLogin', importPath: './auth/login/index.js' },
{ tokens: ['auth', 'devices', 'list'], identifier: 'AuthDevicesList', importPath: './auth/devices/list/index.js' },
{ tokens: ['version'], identifier: 'Version', importPath: './version/index.js' },
]
const tree = buildTree(entries)
expect(tree.subcommands.get('auth')?.command).toBeUndefined()
expect(tree.subcommands.get('auth')?.subcommands.get('login')?.command).toBe('AuthLogin')
expect(tree.subcommands.get('auth')?.subcommands.get('devices')?.subcommands.get('list')?.command)
.toBe('AuthDevicesList')
expect(tree.subcommands.get('version')?.command).toBe('Version')
})
it('supports a parent command with its own children', () => {
const entries = [
{ tokens: ['run', 'app'], identifier: 'RunApp', importPath: './run/app/index.js' },
{ tokens: ['run', 'app', 'resume'], identifier: 'RunAppResume', importPath: './run/app/resume/index.js' },
]
const tree = buildTree(entries)
const runApp = tree.subcommands.get('run')?.subcommands.get('app')
expect(runApp?.command).toBe('RunApp')
expect(runApp?.subcommands.get('resume')?.command).toBe('RunAppResume')
})
})
describe('formatModule', () => {
it('produces a deterministic ESM file with imports + tree literal', () => {
const entries: CommandEntry[] = [
{ tokens: ['auth', 'login'], identifier: 'AuthLogin', importPath: './auth/login/index.js' },
{ tokens: ['version'], identifier: 'Version', importPath: './version/index.js' },
{ tokens: ['auth', 'devices', 'list'], identifier: 'AuthDevicesList', importPath: './auth/devices/list/index.js' },
]
const tree = buildTree(entries)
const out = formatModule(entries, tree)
expect(out).toBe(
`// @generated by scripts/generate-command-tree.ts — DO NOT EDIT.
// Regenerate via \`pnpm tree:gen\`. Drift gated by \`pnpm tree:check\` in CI.
import type { CommandTree } from '../framework/registry.js'
import AuthDevicesList from './auth/devices/list/index.js'
import AuthLogin from './auth/login/index.js'
import Version from './version/index.js'
export const commandTree: CommandTree = {
auth: {
subcommands: {
devices: {
subcommands: {
list: { command: AuthDevicesList, subcommands: {} },
},
},
login: { command: AuthLogin, subcommands: {} },
},
},
version: { command: Version, subcommands: {} },
}
`,
)
})
it('emits parent-with-own-command shape', () => {
const entries: CommandEntry[] = [
{ tokens: ['run', 'app'], identifier: 'RunApp', importPath: './run/app/index.js' },
{ tokens: ['run', 'app', 'resume'], identifier: 'RunAppResume', importPath: './run/app/resume/index.js' },
]
const tree = buildTree(entries)
const out = formatModule(entries, tree)
expect(out).toContain(`run: {
subcommands: {
app: {
command: RunApp,
subcommands: {
resume: { command: RunAppResume, subcommands: {} },
},
},
},
},`)
})
it('imports sorted alphabetically by import path', () => {
const entries: CommandEntry[] = [
{ tokens: ['version'], identifier: 'Version', importPath: './version/index.js' },
{ tokens: ['auth', 'login'], identifier: 'AuthLogin', importPath: './auth/login/index.js' },
]
const out = formatModule(entries, buildTree(entries))
const authIdx = out.indexOf('AuthLogin')
const verIdx = out.indexOf('Version')
expect(authIdx).toBeLessThan(verIdx)
})
})
function makeFixture(): string {
const root = mkdtempSync(join(tmpdir(), 'difyctl-codegen-'))
const commands = join(root, 'src', 'commands')
mkdirSync(join(commands, 'auth', 'login'), { recursive: true })
writeFileSync(join(commands, 'auth', 'login', 'index.ts'), 'export default class Login {}\n')
mkdirSync(join(commands, 'auth', 'devices', 'list'), { recursive: true })
writeFileSync(join(commands, 'auth', 'devices', 'list', 'index.ts'), 'export default class DevicesList {}\n')
mkdirSync(join(commands, '_shared'), { recursive: true })
writeFileSync(join(commands, '_shared', 'index.ts'), 'export default class Shared {}\n')
mkdirSync(join(commands, 'version'), { recursive: true })
writeFileSync(join(commands, 'version', 'index.ts'), 'export default class Version {}\n')
return root
}
describe('discoverCommands', () => {
it('returns sorted entries, skipping _-prefixed segments', async () => {
const root = makeFixture()
const entries = await discoverCommands(join(root, 'src', 'commands'))
expect(entries.map(e => e.tokens.join('/'))).toEqual([
'auth/devices/list',
'auth/login',
'version',
])
expect(entries.find(e => e.tokens[0] === '_shared')).toBeUndefined()
})
it('errors on a loose .ts file under commands/', async () => {
const root = mkdtempSync(join(tmpdir(), 'difyctl-codegen-loose-'))
const commands = join(root, 'src', 'commands')
mkdirSync(commands, { recursive: true })
writeFileSync(join(commands, 'foo.ts'), 'export default class Foo {}\n')
await expect(discoverCommands(commands)).rejects.toThrow(/must live under their own folder/)
})
})
describe('generate', () => {
it('writes tree.generated.ts on default mode', async () => {
const root = makeFixture()
const commandsDir = join(root, 'src', 'commands')
const result = await generate({ commandsDir, mode: 'write' })
expect(result.mode).toBe('write')
const target = join(commandsDir, 'tree.generated.ts')
expect(existsSync(target)).toBe(true)
const content = readFileSync(target, 'utf8')
expect(content).toContain('@generated')
expect(content).toContain('import AuthLogin')
expect(content).toContain('import AuthDevicesList')
expect(content).toContain('import Version')
})
it('returns ok: true on --check when file matches', async () => {
const root = makeFixture()
const commandsDir = join(root, 'src', 'commands')
await generate({ commandsDir, mode: 'write' })
const result = await generate({ commandsDir, mode: 'check' })
if (result.mode !== 'check')
throw new Error('expected check mode')
expect(result.ok).toBe(true)
})
it('returns ok: false on --check when file is stale', async () => {
const root = makeFixture()
const commandsDir = join(root, 'src', 'commands')
await generate({ commandsDir, mode: 'write' })
writeFileSync(join(commandsDir, 'tree.generated.ts'), '// stale\n')
const result = await generate({ commandsDir, mode: 'check' })
if (result.mode !== 'check')
throw new Error('expected check mode')
expect(result.ok).toBe(false)
if (!result.ok)
expect(result.diff).toBeDefined()
})
})