398 lines
12 KiB
TypeScript
398 lines
12 KiB
TypeScript
import { assert, describe, it } from "@effect/vitest"
|
|
import { Config, ConfigProvider, Effect, FileSystem, Layer, Option, Path, Ref, Stdio } from "effect"
|
|
import { TestConsole } from "effect/testing"
|
|
import { Argument, CliError, Flag, Prompt } from "effect/unstable/cli"
|
|
import { ChildProcessSpawner } from "effect/unstable/process"
|
|
import * as MockTerminal from "./services/MockTerminal.ts"
|
|
|
|
const ConsoleLayer = TestConsole.layer
|
|
const FileSystemLayer = FileSystem.layerNoop({})
|
|
const PathLayer = Path.layer
|
|
const TerminalLayer = MockTerminal.layer
|
|
const StdioLayer = Stdio.layerTest({})
|
|
const ChildProcessSpawnerLayer = Layer.succeed(
|
|
ChildProcessSpawner.ChildProcessSpawner,
|
|
ChildProcessSpawner.make(() => Effect.die("Not implemented"))
|
|
)
|
|
|
|
const TestLayer = Layer.mergeAll(
|
|
ConsoleLayer,
|
|
FileSystemLayer,
|
|
PathLayer,
|
|
TerminalLayer,
|
|
StdioLayer,
|
|
ChildProcessSpawnerLayer
|
|
)
|
|
|
|
describe("Param", () => {
|
|
describe("optional", () => {
|
|
it.effect("returns none when an optional boolean flag is omitted", () =>
|
|
Effect.gen(function*() {
|
|
const flag = Flag.boolean("verbose").pipe(Flag.optional)
|
|
|
|
const [, value] = yield* flag.parse({
|
|
flags: {},
|
|
arguments: []
|
|
})
|
|
|
|
assert.deepStrictEqual(value, Option.none())
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
|
|
it.effect("returns some when an optional boolean flag is provided", () =>
|
|
Effect.gen(function*() {
|
|
const flag = Flag.boolean("verbose").pipe(Flag.optional)
|
|
|
|
const [, value] = yield* flag.parse({
|
|
flags: { verbose: ["false"] },
|
|
arguments: []
|
|
})
|
|
|
|
assert.deepStrictEqual(value, Option.some(false))
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
})
|
|
|
|
describe("withFallbackPrompt", () => {
|
|
it.effect("prompts for missing flag values and preserves remaining args", () =>
|
|
Effect.gen(function*() {
|
|
const prompt = Prompt.text({ message: "Name" })
|
|
const flag = Flag.string("name").pipe(Flag.withFallbackPrompt(prompt))
|
|
|
|
yield* MockTerminal.inputText("Chandra")
|
|
yield* MockTerminal.inputKey("enter")
|
|
|
|
const [remaining, value] = yield* flag.parse({
|
|
flags: {},
|
|
arguments: ["tail"]
|
|
})
|
|
|
|
assert.strictEqual(value, "Chandra")
|
|
assert.deepStrictEqual(remaining, ["tail"])
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
|
|
it.effect("does not prompt when flag value is provided", () =>
|
|
Effect.gen(function*() {
|
|
const prompt = Prompt.text({ message: "Name" })
|
|
const flag = Flag.string("name").pipe(Flag.withFallbackPrompt(prompt))
|
|
|
|
const [, value] = yield* flag.parse({
|
|
flags: { name: ["Ava"] },
|
|
arguments: []
|
|
})
|
|
|
|
assert.strictEqual(value, "Ava")
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
|
|
it.effect("prompts for missing arguments", () =>
|
|
Effect.gen(function*() {
|
|
const prompt = Prompt.text({ message: "File" })
|
|
const argument = Argument.string("file").pipe(Argument.withFallbackPrompt(prompt))
|
|
|
|
yield* MockTerminal.inputText("notes.txt")
|
|
yield* MockTerminal.inputKey("enter")
|
|
|
|
const [remaining, value] = yield* argument.parse({
|
|
flags: {},
|
|
arguments: []
|
|
})
|
|
|
|
assert.strictEqual(value, "notes.txt")
|
|
assert.deepStrictEqual(remaining, [])
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
|
|
it.effect("builds flag fallback prompts from effects lazily", () =>
|
|
Effect.gen(function*() {
|
|
const calls = yield* Ref.make(0)
|
|
const prompt = Effect.gen(function*() {
|
|
yield* Ref.update(calls, (n) => n + 1)
|
|
return Prompt.text({ message: "Name from effect" })
|
|
})
|
|
|
|
const flag = Flag.string("name").pipe(Flag.withFallbackPrompt(prompt))
|
|
|
|
const [, provided] = yield* flag.parse({
|
|
flags: { name: ["Ava"] },
|
|
arguments: []
|
|
})
|
|
assert.strictEqual(provided, "Ava")
|
|
assert.strictEqual(yield* Ref.get(calls), 0)
|
|
|
|
yield* MockTerminal.inputText("Mia")
|
|
yield* MockTerminal.inputKey("enter")
|
|
|
|
const [remaining, prompted] = yield* flag.parse({
|
|
flags: {},
|
|
arguments: ["tail"]
|
|
})
|
|
|
|
assert.strictEqual(prompted, "Mia")
|
|
assert.deepStrictEqual(remaining, ["tail"])
|
|
assert.strictEqual(yield* Ref.get(calls), 1)
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
|
|
it.effect("builds argument fallback prompts from effects lazily", () =>
|
|
Effect.gen(function*() {
|
|
const calls = yield* Ref.make(0)
|
|
const prompt = Effect.gen(function*() {
|
|
yield* Ref.update(calls, (n) => n + 1)
|
|
return Prompt.text({ message: "File from effect" })
|
|
})
|
|
|
|
const argument = Argument.string("file").pipe(Argument.withFallbackPrompt(prompt))
|
|
|
|
const [providedRemaining, provided] = yield* argument.parse({
|
|
flags: {},
|
|
arguments: ["notes.txt", "tail"]
|
|
})
|
|
assert.strictEqual(provided, "notes.txt")
|
|
assert.deepStrictEqual(providedRemaining, ["tail"])
|
|
assert.strictEqual(yield* Ref.get(calls), 0)
|
|
|
|
yield* MockTerminal.inputText("draft.md")
|
|
yield* MockTerminal.inputKey("enter")
|
|
|
|
const [promptedRemaining, prompted] = yield* argument.parse({
|
|
flags: {},
|
|
arguments: []
|
|
})
|
|
|
|
assert.strictEqual(prompted, "draft.md")
|
|
assert.deepStrictEqual(promptedRemaining, [])
|
|
assert.strictEqual(yield* Ref.get(calls), 1)
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
|
|
it.effect("propagates errors from effectful fallback prompt creation", () =>
|
|
Effect.gen(function*() {
|
|
const failure = new CliError.UserError({
|
|
cause: "failed to build prompt"
|
|
})
|
|
const prompt = Effect.fail(failure)
|
|
|
|
const flag = Flag.string("name").pipe(Flag.withFallbackPrompt(prompt))
|
|
|
|
const error = yield* Effect.flip(
|
|
flag.parse({
|
|
flags: {},
|
|
arguments: []
|
|
})
|
|
)
|
|
|
|
assert.strictEqual(error, failure)
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
|
|
it.effect("prefers defaults over fallback prompts", () =>
|
|
Effect.gen(function*() {
|
|
const prompt = Prompt.text({ message: "Name" })
|
|
const flag = Flag.string("name").pipe(
|
|
Flag.withDefault("guest"),
|
|
Flag.withFallbackPrompt(prompt)
|
|
)
|
|
|
|
const [, value] = yield* flag.parse({
|
|
flags: {},
|
|
arguments: []
|
|
})
|
|
|
|
assert.strictEqual(value, "guest")
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
|
|
it.effect("does not prompt for invalid flag values", () =>
|
|
Effect.gen(function*() {
|
|
const prompt = Prompt.text({ message: "Count" })
|
|
const flag = Flag.integer("count").pipe(Flag.withFallbackPrompt(prompt))
|
|
|
|
const error = yield* Effect.flip(
|
|
flag.parse({
|
|
flags: { count: ["nope"] },
|
|
arguments: []
|
|
})
|
|
)
|
|
|
|
assert.instanceOf(error, CliError.InvalidValue)
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
|
|
it.effect("does not prompt for invalid argument values", () =>
|
|
Effect.gen(function*() {
|
|
const prompt = Prompt.text({ message: "Count" })
|
|
const argument = Argument.integer("count").pipe(Argument.withFallbackPrompt(prompt))
|
|
|
|
const error = yield* Effect.flip(
|
|
argument.parse({
|
|
flags: {},
|
|
arguments: ["nope"]
|
|
})
|
|
)
|
|
|
|
assert.instanceOf(error, CliError.InvalidValue)
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
|
|
it.effect("does not prompt for missing boolean flags", () =>
|
|
Effect.gen(function*() {
|
|
const prompt = Prompt.text({ message: "Verbose" })
|
|
const flag = Flag.boolean("verbose").pipe(Flag.withFallbackPrompt(prompt))
|
|
|
|
const [, value] = yield* flag.parse({
|
|
flags: {},
|
|
arguments: []
|
|
})
|
|
|
|
assert.strictEqual(value, false)
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
|
|
it.effect("returns MissingOption when prompt is cancelled", () =>
|
|
Effect.gen(function*() {
|
|
const prompt = Prompt.text({ message: "Name" })
|
|
const flag = Flag.string("name").pipe(Flag.withFallbackPrompt(prompt))
|
|
|
|
yield* MockTerminal.inputKey("c", { ctrl: true })
|
|
|
|
const error = yield* Effect.flip(
|
|
flag.parse({
|
|
flags: {},
|
|
arguments: []
|
|
})
|
|
)
|
|
|
|
assert.instanceOf(error, CliError.MissingOption)
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
|
|
it.effect("returns MissingArgument when argument prompt is cancelled", () =>
|
|
Effect.gen(function*() {
|
|
const prompt = Prompt.text({ message: "File" })
|
|
const argument = Argument.string("file").pipe(Argument.withFallbackPrompt(prompt))
|
|
|
|
yield* MockTerminal.inputKey("c", { ctrl: true })
|
|
|
|
const error = yield* Effect.flip(
|
|
argument.parse({
|
|
flags: {},
|
|
arguments: []
|
|
})
|
|
)
|
|
|
|
assert.instanceOf(error, CliError.MissingArgument)
|
|
}).pipe(Effect.provide(TestLayer)))
|
|
})
|
|
|
|
describe("withFallbackConfig", () => {
|
|
it.effect("uses ConfigProvider when a flag is missing", () => {
|
|
const provider = ConfigProvider.fromEnv({
|
|
env: {
|
|
NAME: "Ava"
|
|
}
|
|
})
|
|
|
|
return Effect.gen(function*() {
|
|
const flag = Flag.string("name").pipe(
|
|
Flag.withFallbackConfig(Config.string("NAME"))
|
|
)
|
|
|
|
const [, value] = yield* flag.parse({
|
|
flags: {},
|
|
arguments: []
|
|
})
|
|
|
|
assert.strictEqual(value, "Ava")
|
|
}).pipe(
|
|
Effect.provideService(ConfigProvider.ConfigProvider, provider),
|
|
Effect.provide(TestLayer)
|
|
)
|
|
})
|
|
|
|
it.effect("uses flag values before reading config fallbacks", () => {
|
|
const provider = ConfigProvider.fromEnv({
|
|
env: {
|
|
NAME: "Ava"
|
|
}
|
|
})
|
|
|
|
return Effect.gen(function*() {
|
|
const flag = Flag.string("name").pipe(
|
|
Flag.withFallbackConfig(Config.string("NAME"))
|
|
)
|
|
|
|
const [, value] = yield* flag.parse({
|
|
flags: { name: ["Maya"] },
|
|
arguments: []
|
|
})
|
|
|
|
assert.strictEqual(value, "Maya")
|
|
}).pipe(
|
|
Effect.provideService(ConfigProvider.ConfigProvider, provider),
|
|
Effect.provide(TestLayer)
|
|
)
|
|
})
|
|
|
|
it.effect("uses ConfigProvider when an argument is missing", () => {
|
|
const provider = ConfigProvider.fromEnv({
|
|
env: {
|
|
REPOSITORY: "repo"
|
|
}
|
|
})
|
|
|
|
return Effect.gen(function*() {
|
|
const argument = Argument.string("repository").pipe(
|
|
Argument.withFallbackConfig(Config.string("REPOSITORY"))
|
|
)
|
|
|
|
const [, value] = yield* argument.parse({
|
|
flags: {},
|
|
arguments: []
|
|
})
|
|
|
|
assert.strictEqual(value, "repo")
|
|
}).pipe(
|
|
Effect.provideService(ConfigProvider.ConfigProvider, provider),
|
|
Effect.provide(TestLayer)
|
|
)
|
|
})
|
|
|
|
it.effect("returns MissingOption when config is missing", () => {
|
|
const provider = ConfigProvider.fromEnv({ env: {} })
|
|
|
|
return Effect.gen(function*() {
|
|
const flag = Flag.string("name").pipe(
|
|
Flag.withFallbackConfig(Config.string("NAME"))
|
|
)
|
|
|
|
const error = yield* Effect.flip(
|
|
flag.parse({
|
|
flags: {},
|
|
arguments: []
|
|
})
|
|
)
|
|
|
|
assert.instanceOf(error, CliError.MissingOption)
|
|
}).pipe(
|
|
Effect.provideService(ConfigProvider.ConfigProvider, provider),
|
|
Effect.provide(TestLayer)
|
|
)
|
|
})
|
|
|
|
it.effect("returns InvalidValue when config fails to parse", () => {
|
|
const provider = ConfigProvider.fromEnv({
|
|
env: {
|
|
COUNT: "nope"
|
|
}
|
|
})
|
|
|
|
return Effect.gen(function*() {
|
|
const flag = Flag.integer("count").pipe(
|
|
Flag.withFallbackConfig(Config.int("COUNT"))
|
|
)
|
|
|
|
const error = yield* Effect.flip(
|
|
flag.parse({
|
|
flags: {},
|
|
arguments: []
|
|
})
|
|
)
|
|
|
|
assert.instanceOf(error, CliError.InvalidValue)
|
|
}).pipe(
|
|
Effect.provideService(ConfigProvider.ConfigProvider, provider),
|
|
Effect.provide(TestLayer)
|
|
)
|
|
})
|
|
})
|
|
})
|