| 1 | import * as CLI from "@effect/docgen/CLI" |
| 2 | import * as Configuration from "@effect/docgen/Configuration" |
| 3 | import * as Domain from "@effect/docgen/Domain" |
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices" |
| 5 | import { assert, describe, it } from "@effect/vitest" |
| 6 | import * as ByteSize from "effect/ByteSize" |
| 7 | import * as CliOutput from "effect/cli/CliOutput" |
| 8 | import * as Command from "effect/cli/Command" |
| 9 | import * as Context from "effect/Context" |
| 10 | import * as Effect from "effect/Effect" |
| 11 | import * as Exit from "effect/Exit" |
| 12 | import * as FileSystem from "effect/FileSystem" |
| 13 | import * as Layer from "effect/Layer" |
| 14 | import * as Option from "effect/Option" |
| 15 | import * as Path from "effect/Path" |
| 16 | import * as Result from "effect/Result" |
| 17 | import * as Stdio from "effect/Stdio" |
| 18 | |
| 19 | type DocgenJson = typeof Configuration.ConfigurationSchema.Type |
| 20 | |
| 21 | const existingFile = `${import.meta.dirname}/fixtures/invalid-json.txt` |
| 22 | |
| 23 | const 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 | |
| 40 | class DocgenJsonTag extends Context.Service<DocgenJsonTag, DocgenJson>()("DocgenJsonTag") {} |
| 41 | |
| 42 | const makeDocgenJson = (config: DocgenJson) => Layer.succeed(DocgenJsonTag, config) |
| 43 | |
| 44 | const 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 | |
| 75 | const 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 | |
| 83 | const 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 | |
| 96 | const 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 | |
| 106 | describe("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 | }) |