1import * as CLI from "@effect/docgen/CLI"
2import * as Configuration from "@effect/docgen/Configuration"
3import * as Domain from "@effect/docgen/Domain"
4import * as NodeServices from "@effect/platform-node/NodeServices"
5import { assert, describe, it } from "@effect/vitest"
6import * as ByteSize from "effect/ByteSize"
7import * as CliOutput from "effect/cli/CliOutput"
8import * as Command from "effect/cli/Command"
9import * as Context from "effect/Context"
10import * as Effect from "effect/Effect"
11import * as Exit from "effect/Exit"
12import * as FileSystem from "effect/FileSystem"
13import * as Layer from "effect/Layer"
14import * as Option from "effect/Option"
15import * as Path from "effect/Path"
16import * as Result from "effect/Result"
17import * as Stdio from "effect/Stdio"
18
19type DocgenJson = typeof Configuration.ConfigurationSchema.Type
20
21const existingFile = `${import.meta.dirname}/fixtures/invalid-json.txt`
22
23const fileInfo: FileSystem.File.Info = {
24 type: "File",
25 mtime: Option.none(),
26 atime: Option.none(),
27 birthtime: Option.none(),
28 dev: 0,
29 ino: Option.none(),
30 mode: 0,
31 nlink: Option.none(),
32 uid: Option.none(),
33 gid: Option.none(),
34 rdev: Option.none(),
35 size: ByteSize.zero,
36 blksize: Option.none(),
37 blocks: Option.none()
38}
39
40class DocgenJsonTag extends Context.Service<DocgenJsonTag, DocgenJson>()("DocgenJsonTag") {}
41
42const makeDocgenJson = (config: DocgenJson) => Layer.succeed(DocgenJsonTag, config)
43
44const TestFileSystem = Layer.effect(
45 FileSystem.FileSystem,
46 Effect.gen(function*() {
47 const path = yield* Path.Path
48 const context = yield* Effect.context<never>()
49 const docgenJson = Option.getOrElse(Context.getOption(context, DocgenJsonTag), () => ({} as DocgenJson))
50 const readFileString: FileSystem.FileSystem["readFileString"] = (filePath) => {
51 const fileName = path.basename(filePath)
52 if (fileName === "package.json") {
53 return Effect.succeed(JSON.stringify({ name: "name", homepage: "homepage" }))
54 } else if (fileName === "docgen.json") {
55 return Effect.succeed(JSON.stringify(docgenJson))
56 }
57 return Effect.die(`file not found: ${filePath}`)
58 }
59 const exists: FileSystem.FileSystem["exists"] = (filePath) => {
60 const fileName = path.basename(filePath)
61 if (fileName === "invalid-json.txt") {
62 return Effect.succeed(true)
63 }
64 if (fileName === "docgen.json") {
65 return Effect.succeed(Context.getOption(context, DocgenJsonTag).pipe(Option.isSome))
66 }
67 return Effect.succeed(false)
68 }
69 const stat: FileSystem.FileSystem["stat"] = (filePath) =>
70 path.basename(filePath) === "invalid-json.txt" ? Effect.succeed(fileInfo) : Effect.die("file not found")
71 return FileSystem.makeNoop({ exists, readFileString, stat })
72 })
73).pipe(Layer.provide(Path.layer))
74
75const makeProcess = (env: Record<string, string> = {}) =>
76 Layer.succeed(Domain.Process, {
77 cwd: Effect.sync(() => process.cwd()),
78 platform: Effect.sync(() => process.platform),
79 argv: Effect.sync(() => process.argv),
80 env: Effect.succeed(env)
81 })
82
83const makeTestLayer = (env: Record<string, string> = {}) =>
84 Configuration.configProviderLayer.pipe(
85 Layer.fresh,
86 Layer.provideMerge(Layer.mergeAll(
87 CliOutput.layer(CliOutput.defaultFormatter({ colors: false })),
88 NodeServices.layer,
89 Stdio.layerTest({}),
90 makeProcess(env),
91 Path.layer,
92 TestFileSystem
93 ))
94 )
95
96const testCliFor = (
97 program: Effect.Effect<void, never, Configuration.Configuration | Domain.Process>
98) => {
99 const command = CLI.docgenCommand.pipe(
100 Command.withHandler(() => program),
101 Command.provideEffect(Configuration.Configuration, CLI.loadConfiguration)
102 )
103 return Command.runWith(command, { version: "v1.0.0" })
104}
105
106describe("Configuration", () => {
107 it.effect("should use the default configuration if no configuration is provided", () => {
108 const program = Effect.gen(function*() {
109 const config = yield* Configuration.Configuration
110 assert.deepStrictEqual(config, {
111 projectName: "name",
112 projectHomepage: "homepage",
113 srcLink: "homepage/blob/main/src/",
114 srcDir: "src",
115 outDir: "docs",
116 theme: "mikearnaldi/just-the-docs",
117 enableSearch: true,
118 enforceDescriptions: false,
119 enforceExamples: false,
120 enforceVersion: true,
121 runExamples: false,
122 tscExecutable: "tsc",
123 exclude: [],
124 parseCompilerOptions: Configuration.defaultCompilerOptions,
125 examplesCompilerOptions: Configuration.defaultCompilerOptions
126 })
127 })
128 return testCliFor(program)([]).pipe(Effect.provide(makeTestLayer()))
129 })
130
131 it.effect("should use the configuration contained in docgen.json if it exists", () => {
132 const parseCompilerOptions = {
133 noEmit: true,
134 strict: true,
135 skipLibCheck: true,
136 exactOptionalPropertyTypes: true,
137 moduleResolution: "Bundler",
138 target: "ES2022",
139 lib: ["ES2022", "DOM"]
140 }
141 const program = Effect.gen(function*() {
142 const config = yield* Configuration.Configuration
143 assert.deepStrictEqual(config, {
144 projectName: "name",
145 projectHomepage: "myproject",
146 srcLink: "mygithub",
147 srcDir: "src",
148 outDir: "docs",
149 theme: "mikearnaldi/just-the-docs",
150 enableSearch: true,
151 enforceDescriptions: false,
152 enforceExamples: false,
153 enforceVersion: true,
154 runExamples: false,
155 tscExecutable: "tsc",
156 exclude: [],
157 parseCompilerOptions,
158 examplesCompilerOptions: Configuration.defaultCompilerOptions
159 })
160 })
161 return testCliFor(program)([]).pipe(
162 Effect.provide(
163 makeTestLayer().pipe(Layer.provide(makeDocgenJson({
164 projectHomepage: "myproject",
165 srcLink: "mygithub",
166 parseCompilerOptions
167 })))
168 )
169 )
170 })
171
172 it.effect("should raise a validation error if docgen.json is not valid", () =>
173 Effect.gen(function*() {
174 const result = yield* Effect.exit(
175 testCliFor(Effect.void)([]).pipe(
176 Effect.provide(makeTestLayer().pipe(Layer.provide(makeDocgenJson({ projectHomepage: 1 } as any))))
177 )
178 )
179 if (Exit.isSuccess(result)) {
180 return assert.fail("expected configuration validation to fail")
181 }
182 assert.include(globalThis.String(result.cause), "Configuration.validateJsonFile")
183 assert.include(globalThis.String(result.cause), "projectHomepage")
184 }))
185
186 it.effect("accepts inverse search and version flags", () => {
187 const program = Effect.gen(function*() {
188 const config = yield* Configuration.Configuration
189 assert.isTrue(config.enableSearch)
190 assert.isTrue(config.enforceVersion)
191 })
192 return testCliFor(program)(["--enable-search", "--enforce-version"]).pipe(
193 Effect.provide(
194 makeTestLayer().pipe(Layer.provide(makeDocgenJson({
195 enableSearch: false,
196 enforceVersion: false
197 })))
198 )
199 )
200 })
201
202 it.effect("retains boolean values from docgen.json", () => {
203 const program = Effect.gen(function*() {
204 const config = yield* Configuration.Configuration
205 assert.isFalse(config.enableSearch)
206 assert.isFalse(config.enforceVersion)
207 })
208 return testCliFor(program)([]).pipe(
209 Effect.provide(
210 makeTestLayer().pipe(Layer.provide(makeDocgenJson({
211 enableSearch: false,
212 enforceVersion: false
213 })))
214 )
215 )
216 })
217
218 it.effect("retains primary search and version flags", () => {
219 const program = Effect.gen(function*() {
220 const config = yield* Configuration.Configuration
221 assert.isFalse(config.enableSearch)
222 assert.isFalse(config.enforceVersion)
223 })
224 return testCliFor(program)(["--disable-search", "--no-enforce-version"]).pipe(
225 Effect.provide(makeTestLayer())
226 )
227 })
228
229 it.effect("automatic negative flags override true environment configuration", () => {
230 const program = Effect.gen(function*() {
231 const config = yield* Configuration.Configuration
232 assert.isFalse(config.enforceDescriptions)
233 assert.isFalse(config.enforceExamples)
234 assert.isFalse(config.runExamples)
235 })
236 return testCliFor(program)([
237 "--no-enforce-descriptions",
238 "--no-enforce-examples",
239 "--no-run-examples"
240 ]).pipe(Effect.provide(makeTestLayer({
241 DOCGEN_ENFORCE_DESCRIPTIONS: "true",
242 DOCGEN_ENFORCE_EXAMPLES: "true",
243 DOCGEN_RUN_EXAMPLES: "true"
244 })))
245 })
246
247 it.effect("docgen.json retains its later runExamples precedence", () => {
248 const program = Effect.gen(function*() {
249 const config = yield* Configuration.Configuration
250 assert.isTrue(config.runExamples)
251 })
252 return testCliFor(program)(["--no-run-examples"]).pipe(
253 Effect.provide(
254 makeTestLayer({ DOCGEN_RUN_EXAMPLES: "true" }).pipe(
255 Layer.provide(makeDocgenJson({ runExamples: true }))
256 )
257 )
258 )
259 })
260
261 it.effect("loads comma-delimited environment arrays before docgen.json", () => {
262 const program = Effect.gen(function*() {
263 const process = yield* Domain.Process
264 assert.deepStrictEqual(yield* process.env, { DOCGEN_EXCLUDE: "a,b" })
265 const config = yield* Configuration.Configuration
266 assert.deepStrictEqual(config.exclude, ["a", "b"])
267 })
268 return testCliFor(program)([]).pipe(
269 Effect.provide(
270 makeTestLayer({ DOCGEN_EXCLUDE: "a,b" }).pipe(
271 Layer.provide(makeDocgenJson({ exclude: ["from-docgen"] }))
272 )
273 )
274 )
275 })
276
277 it.effect("parses inline compiler options as JSON records", () => {
278 const program = Effect.gen(function*() {
279 const config = yield* Configuration.Configuration
280 assert.deepStrictEqual(config.parseCompilerOptions, { strict: false })
281 assert.deepStrictEqual(config.examplesCompilerOptions, { module: "ESNext" })
282 })
283 return testCliFor(program)([
284 "--parse-compiler-options",
285 "{\"strict\":false}",
286 "--examples-compiler-options",
287 "{\"module\":\"ESNext\"}"
288 ]).pipe(Effect.provide(makeTestLayer()))
289 })
290
291 it.effect("rejects both compiler option forms for the same category", () =>
292 Effect.gen(function*() {
293 const cases = [
294 ["--parse-tsconfig-file", "--parse-compiler-options"],
295 ["--examples-tsconfig-file", "--examples-compiler-options"]
296 ] as const
297 for (const [fileFlag, inlineFlag] of cases) {
298 const result = yield* Effect.result(
299 testCliFor(Effect.void)([fileFlag, existingFile, inlineFlag, "{}"]).pipe(
300 Effect.provide(makeTestLayer())
301 )
302 )
303 if (Result.isSuccess(result)) {
304 return assert.fail(`${fileFlag} and ${inlineFlag} should be mutually exclusive`)
305 }
306 assert.strictEqual(result.failure._tag, "InvalidValue")
307 if (result.failure._tag === "InvalidValue") {
308 assert.include(result.failure.expected, `only one of ${fileFlag} or ${inlineFlag}`)
309 }
310 }
311 }))
312
313 it.effect("rejects non-record inline compiler options", () =>
314 Effect.gen(function*() {
315 for (const flag of ["--parse-compiler-options", "--examples-compiler-options"]) {
316 for (const value of ["null", "[]", "1", "\"text\""]) {
317 const result = yield* Effect.result(
318 testCliFor(Effect.void)([flag, value]).pipe(Effect.provide(makeTestLayer()))
319 )
320 if (Result.isSuccess(result)) {
321 return assert.fail(`${flag} should reject ${value}`)
322 }
323 assert.strictEqual(result.failure._tag, "ShowHelp")
324 if (result.failure._tag === "ShowHelp") {
325 assert.isTrue(
326 result.failure.errors.some((error) =>
327 error._tag === "InvalidValue" && error.expected.includes("JSON record")
328 )
329 )
330 }
331 }
332 }
333 }))
334})