1import * as Configuration from "@effect/docgen/Configuration"
2import * as Core from "@effect/docgen/Core"
3import * as Domain from "@effect/docgen/Domain"
4import * as Parser from "@effect/docgen/Parser"
5import * as NodeServices from "@effect/platform-node/NodeServices"
6import { assert, describe, it } from "@effect/vitest"
7import * as Effect from "effect/Effect"
8import * as FileSystem from "effect/FileSystem"
9import * as Path from "effect/Path"
10import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"
11import * as Sink from "effect/Sink"
12import * as Stream from "effect/Stream"
13import * as path from "node:path"
14import * as ast from "ts-morph"
15
16const assertFencedCode = (
17 markdown: string,
18 expectedExamples: ReadonlyArray<string>,
19 expectedWarnings: ReadonlyArray<string>
20) => {
21 assert.deepStrictEqual(Core.extractFencedCode(markdown), [expectedExamples, expectedWarnings])
22}
23
24const assertExampleFiles = (source: string, expected: ReadonlyArray<string>, runExamples = true) =>
25 Effect.gen(function*() {
26 const fs = yield* FileSystem.FileSystem
27 const path = yield* Path.Path
28 const cwd = yield* fs.makeTempDirectoryScoped()
29 const config: Configuration.ConfigurationShape = {
30 projectName: "docgen",
31 projectHomepage: "https://example.test",
32 srcLink: "https://example.test",
33 srcDir: path.relative(process.cwd(), path.join(cwd, "src")),
34 outDir: path.relative(process.cwd(), path.join(cwd, "docs")),
35 theme: Configuration.DEFAULT_THEME,
36 enableSearch: true,
37 enforceDescriptions: false,
38 enforceExamples: false,
39 enforceVersion: false,
40 tscExecutable: "tsc",
41 runExamples,
42 exclude: [],
43 parseCompilerOptions: {},
44 examplesCompilerOptions: Configuration.defaultCompilerOptions
45 }
46 yield* fs.makeDirectory(config.srcDir)
47 yield* fs.writeFileString(path.join(config.srcDir, "Box.ts"), source)
48
49 const commands: Array<string> = []
50 // Inspect the generated inputs without invoking a compiler or executing examples.
51 const spawner = ChildProcessSpawner.make((command) =>
52 Effect.gen(function*() {
53 if (command._tag !== "StandardCommand") return yield* Effect.die("Unexpected pipeline")
54 commands.push(command.command)
55 const directory = path.join(config.outDir, "examples")
56 const names = (yield* fs.readDirectory(directory))
57 .filter((name) => name.endsWith(".ts") && name !== "index.ts").sort()
58 const contents = yield* Effect.forEach(names, (name) => fs.readFileString(path.join(directory, name)))
59 assert.deepStrictEqual(contents.sort(), [...expected].sort())
60 const index = yield* fs.readFileString(path.join(directory, "index.ts"))
61 assert.deepStrictEqual(index.trim().split("\n").sort(), names.map((name) => `import './${name.slice(0, -3)}'`))
62 return ChildProcessSpawner.makeHandle({
63 pid: ChildProcessSpawner.ProcessId(1),
64 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
65 isRunning: Effect.succeed(false),
66 kill: () => Effect.void,
67 stdin: Sink.drain,
68 stdout: Stream.empty,
69 stderr: Stream.empty,
70 all: Stream.empty,
71 getInputFd: () => Sink.drain,
72 getOutputFd: () => Stream.empty,
73 unref: Effect.succeed(Effect.void)
74 })
75 })
76 )
77 yield* Core.program.pipe(
78 Effect.provideService(Configuration.Configuration, config),
79 Effect.provideService(Domain.Process, {
80 cwd: Effect.succeed(process.cwd()),
81 platform: Effect.succeed(process.platform),
82 argv: Effect.succeed([]),
83 env: Effect.succeed({})
84 }),
85 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)
86 )
87 const executables = expected.length === 0 ? [] : runExamples ? ["tsc", "tsx"] : ["tsc"]
88 assert.deepStrictEqual(commands, executables.map((name) => process.platform === "win32" ? `${name}.cmd` : name))
89 }).pipe(Effect.provide(NodeServices.layer))
90
91describe("Core", () => {
92 describe("class property examples", { timeout: 20_000 }, () => {
93 // These tests run the full docgen program (including ts-morph and Prettier) in parallel.
94 // Under Bun with other CI projects running, that can exceed Vitest's 5s default.
95 for (const runExamples of [true, false]) {
96 it.effect(`collects property-only examples with runExamples: ${runExamples}`, () =>
97 assertExampleFiles(
98 `export class Box {
99 /**
100 * @example
101 * ~~~ts
102 * const value = 1
103 * ~~~
104 */
105 readonly value = 1
106 }`,
107 ["const value = 1"],
108 runExamples
109 ))
110 }
111
112 it.effect("collects property descriptions and example tags alongside methods, excluding skipped fences", () =>
113 assertExampleFiles(
114 `export class Box {
115 /**
116 * ~~~ts
117 * const description = 1
118 * ~~~
119 * @example
120 * ~~~ts
121 * const first = 1
122 * ~~~
123 * @example
124 * ~~~ts
125 * const second = 2
126 * ~~~
127 * @example
128 * ~~~ts skip-type-checking
129 * const skipped: string = 1
130 * ~~~
131 */
132 readonly value = 1
133 /**
134 * @example
135 * ~~~ts
136 * const method = 3
137 * ~~~
138 */
139 read() { return this.value }
140 }`,
141 ["const description = 1", "const first = 1", "const second = 2", "const method = 3"]
142 ))
143
144 it.effect("does not request validation when all property examples are skipped", () =>
145 assertExampleFiles(
146 `export class Box {
147 /**
148 * @example
149 * ~~~ts skip-type-checking
150 * const skipped: string = 1
151 * ~~~
152 */
153 readonly value = 1
154 }`,
155 []
156 ))
157 })
158
159 // The first case initializes ts-morph and renders Markdown for three modules.
160 // Concurrent Bun CI projects can push that cold start beyond the 5s default.
161 describe("[internal] getModuleMarkdownFiles", { timeout: 20_000 }, () => {
162 for (const srcDir of [".", "src", "source/lib", path.resolve("source/lib"), process.cwd()]) {
163 it.effect(`generates distinct source-relative pages for ${srcDir}`, () =>
164 Effect.gen(function*() {
165 const project = new ast.Project({ useInMemoryFileSystem: true })
166 const sources = [
167 ["left/Shared.ts", "leftMarker"],
168 ["right/Shared.ts", "rightMarker"],
169 ["Flat.ts", "flatMarker"]
170 ] as const
171 const modules = yield* Effect.forEach(sources, ([relativePath, marker]) => {
172 const filePath = path.join(srcDir, relativePath)
173 const content = `/** @since 1.0.0 */\nexport const ${marker} = "${marker}"\n`
174 project.createSourceFile(filePath, content)
175 return Parser.parseFile(project)(new Domain.File(filePath, content))
176 })
177 const files = yield* Core.getModuleMarkdownFiles(modules)
178
179 assert.deepStrictEqual(files.map((file) => file.path), [
180 path.join("docs", "modules", "left", "Shared.ts.md"),
181 path.join("docs", "modules", "right", "Shared.ts.md"),
182 path.join("docs", "modules", "Flat.ts.md")
183 ])
184 for (const [index, file] of files.entries()) {
185 assert.deepStrictEqual(
186 sources.map(([, marker]) => marker).filter((marker) => file.content.includes(marker)),
187 [sources[index][1]]
188 )
189 assert.isTrue(file.isOverwriteable)
190 }
191 }).pipe(
192 Effect.provideService(Configuration.Configuration, {
193 projectName: "docgen",
194 projectHomepage: "https://example.com",
195 srcLink: "https://example.com/src",
196 srcDir,
197 outDir: "docs",
198 theme: Configuration.DEFAULT_THEME,
199 enableSearch: true,
200 enforceDescriptions: false,
201 enforceExamples: false,
202 enforceVersion: true,
203 runExamples: false,
204 tscExecutable: "tsc",
205 exclude: [],
206 parseCompilerOptions: {},
207 examplesCompilerOptions: {}
208 }),
209 Effect.provide(NodeServices.layer)
210 ))
211 }
212 })
213
214 it.effect("preserves a type alias name and declared type parameters", () => {
215 const sourceFile = new ast.Project({ useInMemoryFileSystem: true }).createSourceFile(
216 "Identity.ts",
217 `/**
218 * A boxed value.
219 *
220 * @since 1.0.0
221 */
222 export type Identity<A extends string = string> = A`
223 )
224
225 return Parser.parseTypeAliases.pipe(
226 Effect.provideService(Parser.Source, { path: ["Identity.ts"], sourceFile }),
227 Effect.map(([alias]) => assert.strictEqual(alias?.signature, "type Identity<A extends string = string> = A"))
228 )
229 })
230
231 describe("[internal] extractFencedCode", () => {
232 it("should extract fenced code blocks from markdown (backticks)", () => {
233 assertFencedCode("a\n\n```ts\nconst a = 1\n```\n\nb", ["const a = 1"], [])
234 })
235
236 it("should extract fenced code blocks from markdown (tildes)", () => {
237 assertFencedCode("a\n\n~~~ts\nconst a = 1\n~~~~\n\nb", ["const a = 1"], [])
238 })
239
240 it("should skip-type-checking (backticks)", () => {
241 assertFencedCode("a\n\n```ts skip-type-checking a=1\nconst a = 1\n```\n\nb", [], [])
242 })
243
244 it("should skip-type-checking (tildes)", () => {
245 assertFencedCode("a\n\n~~~ts skip-type-checking a=1\nconst a = 1\n~~~~\n\nb", [], [])
246 })
247
248 it("should handle metadata (backticks)", () => {
249 assertFencedCode("a\n\n```ts a=1\nconst a = 1\n```\n\nb", ["const a = 1"], [])
250 })
251
252 it("should handle metadata (tildes)", () => {
253 assertFencedCode("a\n\n~~~ts a=1\nconst a = 1\n~~~~\n\nb", ["const a = 1"], [])
254 })
255
256 it("should handle non closing fences (backticks)", () => {
257 assertFencedCode("a\n\n```ts\nconst a = 1", ["const a = 1"], [
258 "Code block does not have a matching closing fence:\na\n\n```ts\nconst a = 1"
259 ])
260 })
261
262 it("should handle non closing fences (tildes)", () => {
263 assertFencedCode("a\n\n~~~ts\nconst a = 1", ["const a = 1"], [
264 "Code block does not have a matching closing fence:\na\n\n~~~ts\nconst a = 1"
265 ])
266 })
267 })
268
269 describe("[internal] runCommand", () => {
270 it.effect("streams output without a maxBuffer limit", () =>
271 Effect.gen(function*() {
272 const size = 1024 * 1024 + 1
273 const result = yield* Core.runCommand("node", [
274 "-e",
275 `process.stdout.write("x".repeat(${size})); process.stderr.write("problem"); process.exitCode = 2`
276 ], false)
277 assert.strictEqual(result.stdout.length, size)
278 assert.strictEqual(result.stderr, "problem")
279 assert.strictEqual(result.exitCode, 2)
280 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)))
281 })
282})