573 lines
23 KiB
TypeScript
573 lines
23 KiB
TypeScript
import { describe, expect, it } from "@effect/vitest"
|
|
import { Effect, FileSystem, Layer, Path, Stdio } from "effect"
|
|
import { TestConsole } from "effect/testing"
|
|
import { CliOutput, Command, Flag } from "effect/unstable/cli"
|
|
import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"
|
|
import * as Cli from "./fixtures/ComprehensiveCli.ts"
|
|
import * as MockTerminal from "./services/MockTerminal.ts"
|
|
import * as TestActions from "./services/TestActions.ts"
|
|
|
|
const ActionsLayer = TestActions.layer
|
|
const ConsoleLayer = TestConsole.layer
|
|
const FileSystemLayer = FileSystem.layerNoop({})
|
|
const PathLayer = Path.layer
|
|
const TerminalLayer = MockTerminal.layer
|
|
const CliOutputLayer = CliOutput.layer(
|
|
CliOutput.defaultFormatter({
|
|
colors: false
|
|
})
|
|
)
|
|
|
|
const TestLayer = Layer.mergeAll(
|
|
ActionsLayer,
|
|
ConsoleLayer,
|
|
FileSystemLayer,
|
|
PathLayer,
|
|
TerminalLayer,
|
|
CliOutputLayer,
|
|
Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make(() => Effect.die("Not implemented"))),
|
|
Stdio.layerTest({})
|
|
)
|
|
|
|
const runCommand = Effect.fnUntraced(
|
|
function*(command: ReadonlyArray<string>) {
|
|
yield* Cli.run(command)
|
|
const output = yield* TestConsole.logLines
|
|
return output.join("\n")
|
|
}
|
|
)
|
|
|
|
describe("Command help output", () => {
|
|
it.effect("renders root command help", () =>
|
|
Effect.gen(function*() {
|
|
const helpText = yield* runCommand(["--help"])
|
|
|
|
expect(helpText).toMatchInlineSnapshot(`
|
|
"DESCRIPTION
|
|
A comprehensive CLI tool demonstrating all features
|
|
|
|
USAGE
|
|
mycli <subcommand> [flags]
|
|
|
|
FLAGS
|
|
--debug, -d Enable debug logging
|
|
--config, -c file Path to configuration file
|
|
--quiet, -q Suppress non-error output
|
|
|
|
GLOBAL FLAGS
|
|
--help, -h Show help information
|
|
--version, -v Show version information
|
|
--completions <bash|zsh|fish|sh> Print shell completion script (choices: bash, zsh, fish, sh)
|
|
--log-level <all|trace|debug|info|warn|warning|error|fatal|none> Sets the minimum log level (choices: all, trace, debug, info, warn, warning, error, fatal, none)
|
|
|
|
SUBCOMMANDS
|
|
admin Administrative commands
|
|
copy Copy files or directories
|
|
move Move or rename files
|
|
remove Remove files or directories
|
|
build Build the project
|
|
git Git version control
|
|
test-required Test command with required option
|
|
test-failing Test command that always fails
|
|
app Application management
|
|
app-nested Application with nested services"
|
|
`)
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
|
|
it.effect("aligns flag descriptions when flag names are long", () =>
|
|
Effect.gen(function*() {
|
|
const command = Command.make("tool", {
|
|
short: Flag.string("short").pipe(Flag.withDescription("Short flag description")),
|
|
veryLong: Flag.string("this-is-a-very-very-long-flag-name").pipe(
|
|
Flag.withDescription("Long flag description")
|
|
)
|
|
})
|
|
const run = Command.runWith(command, { version: "1.0.0" })
|
|
|
|
yield* run(["--help"])
|
|
|
|
const helpText = (yield* TestConsole.logLines).join("\n")
|
|
const lines = helpText.split("\n")
|
|
const shortLine = lines.find((line) => line.includes("--short"))
|
|
const longLine = lines.find((line) => line.includes("--this-is-a-very-very-long-flag-name"))
|
|
|
|
expect(shortLine).toBeDefined()
|
|
expect(longLine).toBeDefined()
|
|
expect(shortLine!.indexOf("Short flag description")).toBe(longLine!.indexOf("Long flag description"))
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
|
|
it.effect("hides flags marked with withHidden from help output", () =>
|
|
Effect.gen(function*() {
|
|
const command = Command.make("tool", {
|
|
visible: Flag.string("visible").pipe(Flag.withDescription("Visible flag")),
|
|
secret: Flag.string("experimental-foo").pipe(
|
|
Flag.withDescription("Should not appear"),
|
|
Flag.withHidden
|
|
)
|
|
})
|
|
const run = Command.runWith(command, { version: "1.0.0" })
|
|
|
|
yield* run(["--help"])
|
|
|
|
const helpText = (yield* TestConsole.logLines).join("\n")
|
|
expect(helpText).toContain("--visible")
|
|
expect(helpText).not.toContain("--experimental-foo")
|
|
expect(helpText).not.toContain("Should not appear")
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
|
|
it.effect("hidden flag still parses on the command line", () =>
|
|
Effect.gen(function*() {
|
|
let captured: string | undefined
|
|
const command = Command.make("tool", {
|
|
secret: Flag.string("experimental-foo").pipe(Flag.withHidden)
|
|
}, (config) =>
|
|
Effect.sync(() => {
|
|
captured = config.secret
|
|
}))
|
|
const run = Command.runWith(command, { version: "1.0.0" })
|
|
|
|
yield* run(["--experimental-foo", "value"])
|
|
|
|
expect(captured).toBe("value")
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
|
|
it.effect("hidden flag name does not leak through unrecognized-flag suggestions", () =>
|
|
Effect.gen(function*() {
|
|
const command = Command.make("tool", {
|
|
secret: Flag.string("experimental-foo").pipe(Flag.withHidden)
|
|
}, () => Effect.void)
|
|
const run = Command.runWith(command, { version: "1.0.0" })
|
|
|
|
yield* run(["--experimental-fo", "value"]).pipe(
|
|
Effect.catchTag("ShowHelp", () => Effect.void)
|
|
)
|
|
|
|
const errorText = (yield* TestConsole.errorLines).join("\n")
|
|
const helpText = (yield* TestConsole.logLines).join("\n")
|
|
expect(errorText + helpText).not.toContain("experimental-foo")
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
|
|
it.effect("hides subcommands marked with withHidden from help output", () =>
|
|
Effect.gen(function*() {
|
|
const visible = Command.make("visible").pipe(
|
|
Command.withDescription("A visible subcommand")
|
|
)
|
|
const secret = Command.make("experimental-foo").pipe(
|
|
Command.withDescription("Should not appear"),
|
|
Command.withHidden
|
|
)
|
|
const root = Command.make("tool").pipe(
|
|
Command.withSubcommands([visible, secret])
|
|
)
|
|
const run = Command.runWith(root, { version: "1.0.0" })
|
|
|
|
yield* run(["--help"])
|
|
|
|
const helpText = (yield* TestConsole.logLines).join("\n")
|
|
expect(helpText).toContain("visible")
|
|
expect(helpText).not.toContain("experimental-foo")
|
|
expect(helpText).not.toContain("Should not appear")
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
|
|
it.effect("hidden subcommand still parses on the command line", () =>
|
|
Effect.gen(function*() {
|
|
let invoked = false
|
|
const secret = Command.make("experimental-foo").pipe(
|
|
Command.withHidden,
|
|
Command.withHandler(() =>
|
|
Effect.sync(() => {
|
|
invoked = true
|
|
})
|
|
)
|
|
)
|
|
const root = Command.make("tool").pipe(
|
|
Command.withSubcommands([secret])
|
|
)
|
|
const run = Command.runWith(root, { version: "1.0.0" })
|
|
|
|
yield* run(["experimental-foo"])
|
|
|
|
expect(invoked).toBe(true)
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
|
|
it.effect("hidden subcommand name does not leak through unknown-subcommand suggestions", () =>
|
|
Effect.gen(function*() {
|
|
const secret = Command.make("experimental-foo").pipe(Command.withHidden)
|
|
const root = Command.make("tool").pipe(
|
|
Command.withSubcommands([secret])
|
|
)
|
|
const run = Command.runWith(root, { version: "1.0.0" })
|
|
|
|
yield* run(["experimental-fo"]).pipe(
|
|
Effect.catchTag("ShowHelp", () => Effect.void)
|
|
)
|
|
|
|
const errorText = (yield* TestConsole.errorLines).join("\n")
|
|
const helpText = (yield* TestConsole.logLines).join("\n")
|
|
expect(errorText + helpText).not.toContain("experimental-foo")
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
|
|
it.effect("subcommand group with only hidden commands disappears entirely", () =>
|
|
Effect.gen(function*() {
|
|
const secret = Command.make("experimental-foo").pipe(Command.withHidden)
|
|
const root = Command.make("tool").pipe(
|
|
Command.withSubcommands([secret])
|
|
)
|
|
const run = Command.runWith(root, { version: "1.0.0" })
|
|
|
|
yield* run(["--help"])
|
|
|
|
const helpText = (yield* TestConsole.logLines).join("\n")
|
|
expect(helpText).not.toContain("SUBCOMMANDS")
|
|
expect(helpText).not.toContain("<subcommand>")
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
|
|
it.effect("renders command examples", () =>
|
|
Effect.gen(function*() {
|
|
const command = Command.make("login").pipe(
|
|
Command.withDescription("Authenticate with Supabase"),
|
|
Command.withExamples([
|
|
{ command: "myapp login", description: "Log in with browser OAuth" },
|
|
{ command: "myapp login --token sbp_abc123", description: "Log in with a token" },
|
|
{ command: "myapp login --logout" },
|
|
{ command: "myapp login --logout" },
|
|
{ command: "myapp login", description: "Log in with browser OAuth" }
|
|
])
|
|
)
|
|
const runLogin = Command.runWith(command, { version: "1.0.0" })
|
|
|
|
yield* runLogin(["--help"])
|
|
|
|
const output = (yield* TestConsole.logLines).join("\n")
|
|
expect(output).toMatchInlineSnapshot(`
|
|
"DESCRIPTION
|
|
Authenticate with Supabase
|
|
|
|
USAGE
|
|
login [flags]
|
|
|
|
GLOBAL FLAGS
|
|
--help, -h Show help information
|
|
--version, -v Show version information
|
|
--completions <bash|zsh|fish|sh> Print shell completion script (choices: bash, zsh, fish, sh)
|
|
--log-level <all|trace|debug|info|warn|warning|error|fatal|none> Sets the minimum log level (choices: all, trace, debug, info, warn, warning, error, fatal, none)
|
|
|
|
EXAMPLES
|
|
# Log in with browser OAuth
|
|
myapp login
|
|
|
|
# Log in with a token
|
|
myapp login --token sbp_abc123
|
|
|
|
myapp login --logout
|
|
myapp login --logout
|
|
|
|
# Log in with browser OAuth
|
|
myapp login"
|
|
`)
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
|
|
it.effect("renders file command positional arguments", () =>
|
|
Effect.gen(function*() {
|
|
const helpText = yield* runCommand(["copy", "--help"])
|
|
|
|
expect(helpText).toMatchInlineSnapshot(`
|
|
"DESCRIPTION
|
|
Copy files or directories
|
|
|
|
USAGE
|
|
mycli copy [flags] <source> <destination>
|
|
|
|
ARGUMENTS
|
|
source file Source file or directory
|
|
destination file Destination path
|
|
|
|
FLAGS
|
|
--debug, -d Enable debug logging
|
|
--config, -c file Path to configuration file
|
|
--quiet, -q Suppress non-error output
|
|
--recursive, -r Copy directories recursively
|
|
--force, -f Overwrite existing files
|
|
--buffer-size integer Buffer size in KB
|
|
|
|
GLOBAL FLAGS
|
|
--help, -h Show help information
|
|
--version, -v Show version information
|
|
--completions <bash|zsh|fish|sh> Print shell completion script (choices: bash, zsh, fish, sh)
|
|
--log-level <all|trace|debug|info|warn|warning|error|fatal|none> Sets the minimum log level (choices: all, trace, debug, info, warn, warning, error, fatal, none)"
|
|
`)
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
|
|
it.effect("renders variadic arguments", () =>
|
|
Effect.gen(function*() {
|
|
const helpText = yield* runCommand(["remove", "--help"])
|
|
|
|
expect(helpText).toMatchInlineSnapshot(`
|
|
"DESCRIPTION
|
|
Remove files or directories
|
|
|
|
USAGE
|
|
mycli remove [flags] <files...>
|
|
|
|
ARGUMENTS
|
|
files... string Files to remove
|
|
|
|
FLAGS
|
|
--debug, -d Enable debug logging
|
|
--config, -c file Path to configuration file
|
|
--quiet, -q Suppress non-error output
|
|
--recursive, -r Remove directories and contents
|
|
--force, -f Force removal without prompts
|
|
--verbose, -v Explain what is being done
|
|
|
|
GLOBAL FLAGS
|
|
--help, -h Show help information
|
|
--version, -v Show version information
|
|
--completions <bash|zsh|fish|sh> Print shell completion script (choices: bash, zsh, fish, sh)
|
|
--log-level <all|trace|debug|info|warn|warning|error|fatal|none> Sets the minimum log level (choices: all, trace, debug, info, warn, warning, error, fatal, none)"
|
|
`)
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
|
|
it.effect("renders deeply nested subcommand help", () =>
|
|
Effect.gen(function*() {
|
|
const helpText = yield* runCommand(["admin", "users", "list", "--help"])
|
|
|
|
expect(helpText).toMatchInlineSnapshot(`
|
|
"DESCRIPTION
|
|
List all users in the system
|
|
|
|
USAGE
|
|
mycli admin users list [flags]
|
|
|
|
FLAGS
|
|
--debug, -d Enable debug logging
|
|
--config, -c file Path to configuration file
|
|
--quiet, -q Suppress non-error output
|
|
--sudo Run with elevated privileges
|
|
--format string Output format (json, table, csv)
|
|
--active Show only active users
|
|
--verbose, -v Show detailed information
|
|
|
|
GLOBAL FLAGS
|
|
--help, -h Show help information
|
|
--version, -v Show version information
|
|
--completions <bash|zsh|fish|sh> Print shell completion script (choices: bash, zsh, fish, sh)
|
|
--log-level <all|trace|debug|info|warn|warning|error|fatal|none> Sets the minimum log level (choices: all, trace, debug, info, warn, warning, error, fatal, none)"
|
|
`)
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
|
|
it.effect("renders mixed required and optional positional arguments", () =>
|
|
Effect.gen(function*() {
|
|
const helpText = yield* runCommand(["admin", "users", "create", "--help"])
|
|
|
|
expect(helpText).toMatchInlineSnapshot(`
|
|
"DESCRIPTION
|
|
Create a new user account
|
|
|
|
USAGE
|
|
mycli admin users create [flags] <username> [<email>]
|
|
|
|
ARGUMENTS
|
|
username string Username for the new user
|
|
email string Email address (optional) (optional)
|
|
|
|
FLAGS
|
|
--debug, -d Enable debug logging
|
|
--config, -c file Path to configuration file
|
|
--quiet, -q Suppress non-error output
|
|
--sudo Run with elevated privileges
|
|
--role string User role (admin, user, guest)
|
|
--notify, -n Send notification email
|
|
|
|
GLOBAL FLAGS
|
|
--help, -h Show help information
|
|
--version, -v Show version information
|
|
--completions <bash|zsh|fish|sh> Print shell completion script (choices: bash, zsh, fish, sh)
|
|
--log-level <all|trace|debug|info|warn|warning|error|fatal|none> Sets the minimum log level (choices: all, trace, debug, info, warn, warning, error, fatal, none)"
|
|
`)
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
|
|
it.effect("renders intermediate subcommand shared flags and children", () =>
|
|
Effect.gen(function*() {
|
|
const helpText = yield* runCommand(["admin", "config", "--help"])
|
|
|
|
expect(helpText).toMatchInlineSnapshot(`
|
|
"DESCRIPTION
|
|
Manage application configuration
|
|
|
|
USAGE
|
|
mycli admin config <subcommand> [flags]
|
|
|
|
FLAGS
|
|
--debug, -d Enable debug logging
|
|
--config, -c file Path to configuration file
|
|
--quiet, -q Suppress non-error output
|
|
--sudo Run with elevated privileges
|
|
--profile, -p string Configuration profile to use
|
|
|
|
GLOBAL FLAGS
|
|
--help, -h Show help information
|
|
--version, -v Show version information
|
|
--completions <bash|zsh|fish|sh> Print shell completion script (choices: bash, zsh, fish, sh)
|
|
--log-level <all|trace|debug|info|warn|warning|error|fatal|none> Sets the minimum log level (choices: all, trace, debug, info, warn, warning, error, fatal, none)
|
|
|
|
SUBCOMMANDS
|
|
set Set configuration values
|
|
get Get configuration value"
|
|
`)
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
|
|
it.effect("renders variadic arguments with a minimum count", () =>
|
|
Effect.gen(function*() {
|
|
const helpText = yield* runCommand(["admin", "config", "set", "--help"])
|
|
|
|
expect(helpText).toMatchInlineSnapshot(`
|
|
"DESCRIPTION
|
|
Set configuration values
|
|
|
|
USAGE
|
|
mycli admin config set [flags] <key=value...>
|
|
|
|
ARGUMENTS
|
|
key=value... string Configuration key-value pairs
|
|
|
|
FLAGS
|
|
--debug, -d Enable debug logging
|
|
--config, -c file Path to configuration file
|
|
--quiet, -q Suppress non-error output
|
|
--sudo Run with elevated privileges
|
|
--profile, -p string Configuration profile to use
|
|
--config-file, -f file Write to specific config file
|
|
|
|
GLOBAL FLAGS
|
|
--help, -h Show help information
|
|
--version, -v Show version information
|
|
--completions <bash|zsh|fish|sh> Print shell completion script (choices: bash, zsh, fish, sh)
|
|
--log-level <all|trace|debug|info|warn|warning|error|fatal|none> Sets the minimum log level (choices: all, trace, debug, info, warn, warning, error, fatal, none)"
|
|
`)
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
|
|
it.effect("shared flags are visible in subcommand help while local flags stay local", () =>
|
|
Effect.gen(function*() {
|
|
const root = Command.make("tool", {
|
|
workspace: Flag.string("workspace")
|
|
}).pipe(
|
|
Command.withSharedFlags({
|
|
model: Flag.string("model")
|
|
}),
|
|
Command.withSubcommands([
|
|
Command.make("chat", {
|
|
topic: Flag.string("topic")
|
|
})
|
|
])
|
|
)
|
|
|
|
const runRoot = Command.runWith(root, { version: "1.0.0" })
|
|
|
|
yield* runRoot(["--help"])
|
|
const rootHelp = yield* TestConsole.logLines
|
|
expect(rootHelp.some((line) => String(line).includes("--workspace"))).toBe(true)
|
|
expect(rootHelp.some((line) => String(line).includes("--model"))).toBe(true)
|
|
|
|
yield* runRoot(["chat", "--help"])
|
|
const allHelp = yield* TestConsole.logLines
|
|
const chatHelp = allHelp.slice(rootHelp.length)
|
|
expect(chatHelp.some((line) => String(line).includes("--workspace"))).toBe(false)
|
|
expect(chatHelp.some((line) => String(line).includes("--model"))).toBe(true)
|
|
expect(chatHelp.some((line) => String(line).includes("--topic"))).toBe(true)
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
|
|
it.effect("renders grouped subcommands", () =>
|
|
Effect.gen(function*() {
|
|
const ungrouped = Command.make("ungrouped").pipe(
|
|
Command.withDescription("This command is not in a group")
|
|
)
|
|
const init = Command.make("init").pipe(Command.withDescription("Create a new project"))
|
|
const login = Command.make("login").pipe(Command.withDescription("Authenticate with the platform"))
|
|
const start = Command.make("start").pipe(Command.withDescription("Start local services"))
|
|
const stop = Command.make("stop").pipe(Command.withDescription("Stop local services"))
|
|
const db = Command.make("db").pipe(Command.withDescription("Manage local database"))
|
|
const projects = Command.make("projects").pipe(Command.withDescription("Manage cloud projects"))
|
|
const functions = Command.make("functions").pipe(Command.withDescription("Manage edge functions"))
|
|
|
|
const grouped = Command.make("tool").pipe(
|
|
Command.withSubcommands([
|
|
{
|
|
group: "Quick Start",
|
|
commands: [init, login]
|
|
},
|
|
{
|
|
group: "Local Development",
|
|
commands: [start, stop, db]
|
|
},
|
|
{
|
|
group: "Management APIs",
|
|
commands: [projects, functions]
|
|
},
|
|
ungrouped
|
|
])
|
|
)
|
|
|
|
const runGrouped = Command.runWith(grouped, { version: "1.0.0" })
|
|
yield* runGrouped(["--help"])
|
|
|
|
const helpText = (yield* TestConsole.logLines).join("\n")
|
|
|
|
expect(helpText).toMatchInlineSnapshot(`
|
|
"USAGE
|
|
tool <subcommand> [flags]
|
|
|
|
GLOBAL FLAGS
|
|
--help, -h Show help information
|
|
--version, -v Show version information
|
|
--completions <bash|zsh|fish|sh> Print shell completion script (choices: bash, zsh, fish, sh)
|
|
--log-level <all|trace|debug|info|warn|warning|error|fatal|none> Sets the minimum log level (choices: all, trace, debug, info, warn, warning, error, fatal, none)
|
|
|
|
SUBCOMMANDS
|
|
ungrouped This command is not in a group
|
|
|
|
Quick Start:
|
|
init Create a new project
|
|
login Authenticate with the platform
|
|
|
|
Local Development:
|
|
start Start local services
|
|
stop Stop local services
|
|
db Manage local database
|
|
|
|
Management APIs:
|
|
projects Manage cloud projects
|
|
functions Manage edge functions"
|
|
`)
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
|
|
it.effect("renders subcommand aliases in listings", () =>
|
|
Effect.gen(function*() {
|
|
const plan = Command.make("plan").pipe(
|
|
Command.withAlias("p"),
|
|
Command.withDescription("Draft a plan in your editor")
|
|
)
|
|
|
|
const root = Command.make("tool").pipe(Command.withSubcommands([plan]))
|
|
const runRoot = Command.runWith(root, { version: "1.0.0" })
|
|
|
|
yield* runRoot(["--help"])
|
|
|
|
const helpText = (yield* TestConsole.logLines).join("\n")
|
|
|
|
expect(helpText).toMatchInlineSnapshot(`
|
|
"USAGE
|
|
tool <subcommand> [flags]
|
|
|
|
GLOBAL FLAGS
|
|
--help, -h Show help information
|
|
--version, -v Show version information
|
|
--completions <bash|zsh|fish|sh> Print shell completion script (choices: bash, zsh, fish, sh)
|
|
--log-level <all|trace|debug|info|warn|warning|error|fatal|none> Sets the minimum log level (choices: all, trace, debug, info, warn, warning, error, fatal, none)
|
|
|
|
SUBCOMMANDS
|
|
plan, p Draft a plan in your editor"
|
|
`)
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
})
|