1import * as Doctest from "@effect/doctest/Plugin"
2import * as Protocol from "@effect/doctest/Protocol"
3import { assert, describe, it } from "@effect/vitest"
4import { rejects } from "node:assert/strict"
5import { spawnSync } from "node:child_process"
6import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
7import { createRequire, SourceMap } from "node:module"
8import { tmpdir } from "node:os"
9import { dirname, join, relative } from "node:path"
10import { compileFunction } from "node:vm"
11import type { JsonTestResults } from "vitest/node"
12
13describe("Plugin", () => {
14 it("executes Markdown, MDX and JSDoc snippets through Vitest", () => {
15 const output = mkdtempSync(join(tmpdir(), "effect-doctest-report-"))
16 try {
17 const reportFile = join(output, "report.json")
18 const result = spawnSync("node", [
19 join(dirname(createRequire(import.meta.url).resolve("vitest/package.json")), "vitest.mjs"),
20 "run",
21 "--config",
22 "vitest.config.ts",
23 "--reporter=default",
24 "--reporter=json",
25 "--outputFile.json",
26 reportFile
27 ], {
28 cwd: join(import.meta.dirname, "fixtures/markdown"),
29 encoding: "utf8",
30 timeout: 30_000
31 })
32 assert.strictEqual(result.status, 0, `${result.error ?? ""}\n${result.stdout}\n${result.stderr}`)
33 const report: JsonTestResults = JSON.parse(readFileSync(reportFile, "utf8"))
34 assert.deepStrictEqual(
35 report.testResults.flatMap((file) => file.assertionResults.map((test) => [test.fullName, test.status])).sort(),
36 [
37 ["assertion-comments", "passed"],
38 ["dynamic-import", "passed"],
39 ["javascript", "passed"],
40 ["static-import", "passed"],
41 ["typed-jsdoc", "passed"],
42 ["typed-mdx", "passed"]
43 ]
44 )
45 } finally {
46 rmSync(output, { recursive: true, force: true })
47 }
48 }, 40_000)
49
50 it("configures the doctest runner by default", () => {
51 const config = Doctest.plugin().config
52 if (typeof config !== "function") return assert.fail("expected config hook")
53
54 assert.deepStrictEqual(config.call({} as never, {}, {} as never), {
55 test: { runner: "@effect/doctest/Runner" }
56 })
57 })
58
59 it("preserves a configured runner", () => {
60 const config = Doctest.plugin().config
61 if (typeof config !== "function") return assert.fail("expected config hook")
62
63 assert.isUndefined(config.call({} as never, { test: { runner: "./custom-runner.ts" } }, {} as never))
64 })
65
66 it("preserves statement boundaries after inline assertions", async () => {
67 const root = mkdtempSync(join(tmpdir(), "effect-doctest-plugin-"))
68 const file = join(root, "example.ts")
69 writeFileSync(
70 file,
71 [
72 "/**",
73 " * ```ts import.meta.vitest name=asserted",
74 " * const result = 1; // => 1",
75 " * [1, 2].forEach(record)",
76 " * ```",
77 " */"
78 ].join("\n")
79 )
80
81 try {
82 const plugin = Doctest.plugin()
83 const resolveId = plugin.resolveId
84 const load = plugin.load
85 if (typeof resolveId !== "function" || typeof load !== "function") {
86 return assert.fail("expected function plugin hooks")
87 }
88 const context = { addWatchFile() {} } as never
89 const id = Protocol.collectorId(file, "test")
90 const resolved = await resolveId.call(context, id, undefined, {} as never)
91 if (typeof resolved !== "string") return assert.fail("expected resolved collector ID")
92 const source = await load.call(context, resolved, {} as never)
93 if (typeof source !== "string") return assert.fail("expected collector source")
94
95 assert.match(source, /import \{ test \} from "@effect\/doctest\/Runtime"/)
96 assert.match(source, /test\("asserted", \(\) => import\([^)]*\)\)/)
97
98 const snippetId = Protocol.snippetId(file, 0, "test")
99 const resolvedSnippet = await resolveId.call(context, snippetId, undefined, {} as never)
100 if (typeof resolvedSnippet !== "string") return assert.fail("expected resolved snippet ID")
101 const snippet = await load.call(context, resolvedSnippet, {} as never)
102 if (typeof snippet !== "string") return assert.fail("expected snippet source")
103 assert.match(snippet, /const result = 1;\n__effect_doctest_assert_0\(result, 1\)/)
104 assert.match(snippet, /assertEquals as __effect_doctest_assert_0/)
105
106 const values: Array<number> = []
107 compileFunction(snippet.slice(0, snippet.lastIndexOf("\n\nimport ")), ["__effect_doctest_assert_0", "record"])(
108 (actual: unknown, expected: unknown) => assert.deepStrictEqual(actual, expected),
109 (value: number) => values.push(value)
110 )
111 assert.deepStrictEqual(values, [1, 2])
112 } finally {
113 rmSync(root, { recursive: true, force: true })
114 }
115 })
116
117 it("lowers TypeScript without changing the snippet identity and maps to the document line", async () => {
118 const root = mkdtempSync(join(tmpdir(), "effect-doctest-markdown-"))
119 try {
120 const file = join(root, "example.md")
121 writeFileSync(file, "# Example\n\n```ts import.meta.vitest\nconst local: number = 42\nrecord(local)\n```\n")
122 const plugin = Doctest.plugin()
123 const resolveId = plugin.resolveId
124 const load = plugin.load
125 if (typeof resolveId !== "function" || typeof load !== "function") {
126 return assert.fail("expected function plugin hooks")
127 }
128 const context = { addWatchFile() {} } as never
129 const collector = Protocol.resolvedId("collector", { file, version: "test" })
130 await load.call(context, collector, {} as never)
131 const resolved = await resolveId.call(context, Protocol.snippetId(file, 0, "test"), undefined, {} as never)
132 assert.strictEqual(resolved, Protocol.resolvedId("snippet", { file, index: 0, version: "test" }))
133 if (typeof resolved !== "string") return assert.fail("expected string ID")
134 const output = await load.call(context, resolved, {} as never)
135 if (output === null || typeof output !== "object") return assert.fail("expected compiled snippet")
136 const values: Array<unknown> = []
137 compileFunction(output.code, ["record"])((value: unknown) => values.push(value))
138 assert.deepStrictEqual(values, [42])
139 if (output.map === null || output.map === undefined || typeof output.map === "string") {
140 return assert.fail("expected source map")
141 }
142 assert.deepStrictEqual(output.map.sources, [file])
143 const line = output.code.split("\n").findIndex((line) => line.includes("record(local)"))
144 const mapping = new SourceMap({
145 version: 3,
146 file,
147 sourceRoot: "",
148 sources: output.map.sources?.map((source) => source ?? "") ?? [],
149 sourcesContent: output.map.sourcesContent?.map((source) => source ?? "") ?? [],
150 names: output.map.names ?? [],
151 mappings: output.map.mappings
152 }).findEntry(line, 0)
153 assert.deepInclude(mapping, { originalSource: file, originalLine: 4 })
154 } finally {
155 rmSync(root, { recursive: true, force: true })
156 }
157 })
158
159 it("leaves normal TypeScript snippets for Vite to compile once", async () => {
160 const root = mkdtempSync(join(tmpdir(), "effect-doctest-typescript-"))
161 try {
162 const file = join(root, "example.ts")
163 writeFileSync(file, "/**\n * ```ts import.meta.vitest\n * const local: number = 42\n * ```\n */\n")
164 const load = Doctest.plugin().load
165 if (typeof load !== "function") return assert.fail("expected load hook")
166 const output = await load.call(
167 { addWatchFile() {} } as never,
168 Protocol.resolvedId("snippet", { file, index: 0, version: "test" }),
169 {} as never
170 )
171 assert.strictEqual(output, "const local: number = 42")
172 } finally {
173 rmSync(root, { recursive: true, force: true })
174 }
175 })
176
177 it("reports invalid TypeScript using the document filename and line", async () => {
178 const root = mkdtempSync(join(tmpdir(), "effect-doctest-invalid-"))
179 try {
180 const file = join(root, "example.md")
181 writeFileSync(file, "# Example\n\n```ts import.meta.vitest\nconst local: = 42\n```\n")
182 const load = Doctest.plugin().load
183 if (typeof load !== "function") return assert.fail("expected load hook")
184 await rejects(
185 Promise.resolve(load.call(
186 { addWatchFile() {} } as never,
187 Protocol.resolvedId("snippet", { file, index: 0, version: "test" }),
188 {} as never
189 )),
190 (error: unknown) => {
191 assert.include(String(error), `${relative(process.cwd(), file)}:4:14`)
192 return true
193 }
194 )
195 } finally {
196 rmSync(root, { recursive: true, force: true })
197 }
198 })
199})