Skip to content

Commit 28f2ed9

Browse files
committed
test: add unit tests for script-driven generators
This covers discovery (a script manifest installs a `ScriptGenerator`), the output writer (writes under the root, rejects absolute and escaping paths), and both runners against a synthetic corpus, asserting the file they emit. A regression test reads a symbol with no name field, exercising the `Undefined`-to-`nil` marshalling a real corpus needs.
1 parent d325c7a commit 28f2ed9

1 file changed

Lines changed: 270 additions & 0 deletions

File tree

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
//
2+
// Licensed under the Apache License v2.0 with LLVM Exceptions.
3+
// See https://llvm.org/LICENSE.txt for license information.
4+
// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
5+
//
6+
// Copyright (c) 2026 Gennaro Prota (gennaro.prota@gmail.com)
7+
//
8+
// Official repository: https://github.com/cppalliance/mrdocs
9+
//
10+
11+
#include <lib/Gen/script/OutputSink.hpp>
12+
#include <lib/Gen/script/ScriptGenerator.hpp>
13+
#include <lib/Gen/script/ScriptRunner.hpp>
14+
#include <lib/Support/Path.hpp>
15+
#include <mrdocs/Config.hpp>
16+
#include <mrdocs/Dom.hpp>
17+
#include <mrdocs/Generator.hpp>
18+
#include <mrdocs/Support/Path.hpp>
19+
#include <test_suite/test_suite.hpp>
20+
#include <fstream>
21+
#include <string>
22+
#include <string_view>
23+
#include <utility>
24+
25+
namespace mrdocs::script {
26+
27+
namespace {
28+
29+
// Write `content` verbatim to `path`. Pre-existing files are truncated.
30+
void
31+
writeFile(std::string_view path, std::string_view content)
32+
{
33+
std::ofstream os(std::string{path}, std::ios::binary | std::ios::trunc);
34+
os.write(content.data(),
35+
static_cast<std::streamsize>(content.size()));
36+
}
37+
38+
// A two-symbol corpus shaped like what `generate(corpus, output)` sees:
39+
// a `symbols` array whose entries carry a `name` and a flat `_id`.
40+
dom::Value
41+
makeCorpus()
42+
{
43+
dom::Object foo;
44+
foo.set("name", std::string("foo"));
45+
foo.set("_id", std::string("0001"));
46+
dom::Object bar;
47+
bar.set("name", std::string("bar"));
48+
bar.set("_id", std::string("0002"));
49+
dom::Array symbols;
50+
symbols.emplace_back(dom::Value(std::move(foo)));
51+
symbols.emplace_back(dom::Value(std::move(bar)));
52+
dom::Object corpus;
53+
corpus.set("symbols", std::move(symbols));
54+
return dom::Value(std::move(corpus));
55+
}
56+
57+
// A Lua generator that emits one aggregated artifact across all symbols,
58+
// the canonical thing a per-page generator cannot produce.
59+
constexpr std::string_view luaIndex = R"LUA(
60+
return function(corpus, output)
61+
local parts = {}
62+
for _, sym in ipairs(corpus.symbols) do
63+
parts[#parts + 1] = '{"name":"' .. sym.name .. '","id":"' .. sym._id .. '"}'
64+
end
65+
output.write("search-index.json", "[" .. table.concat(parts, ",") .. "]")
66+
end
67+
)LUA";
68+
69+
// The same generator in JavaScript, using the global-function shape.
70+
constexpr std::string_view jsIndex = R"JS(
71+
function generate(corpus, output) {
72+
var parts = [];
73+
for (var i = 0; i < corpus.symbols.length; i++) {
74+
var s = corpus.symbols[i];
75+
parts.push('{"name":"' + s.name + '","id":"' + s._id + '"}');
76+
}
77+
output.write("search-index.json", "[" + parts.join(",") + "]");
78+
}
79+
)JS";
80+
81+
constexpr std::string_view expectedJson =
82+
R"([{"name":"foo","id":"0001"},{"name":"bar","id":"0002"}])";
83+
84+
} // (anon)
85+
86+
struct ScriptGeneratorTest
87+
{
88+
//
89+
// OutputSink
90+
//
91+
92+
void
93+
testSinkWritesUnderRoot()
94+
{
95+
ScopedTempDirectory td("mrdocs-scriptgen");
96+
BOOST_TEST(td);
97+
OutputSink sink(td.path());
98+
// A nested relative path is created and written.
99+
BOOST_TEST(sink.write("a/b/out.txt", "hello").has_value());
100+
Expected<std::string> got =
101+
files::getFileText(files::appendPath(td.path(), "a", "b", "out.txt"));
102+
BOOST_TEST(got.has_value());
103+
if (got)
104+
{
105+
BOOST_TEST(*got == "hello");
106+
}
107+
}
108+
109+
void
110+
testSinkRejectsAbsolutePath()
111+
{
112+
ScopedTempDirectory td("mrdocs-scriptgen");
113+
BOOST_TEST(td);
114+
OutputSink sink(td.path());
115+
// An absolute path is rejected even when it points inside root.
116+
std::string const abs = files::appendPath(td.path(), "x.txt");
117+
BOOST_TEST(!sink.write(abs, "no").has_value());
118+
}
119+
120+
void
121+
testSinkRejectsEscape()
122+
{
123+
ScopedTempDirectory td("mrdocs-scriptgen");
124+
BOOST_TEST(td);
125+
OutputSink sink(td.path());
126+
// A path that climbs out of the output directory is rejected.
127+
BOOST_TEST(!sink.write("../escaped.txt", "no").has_value());
128+
}
129+
130+
//
131+
// runLuaGenerator / runJsGenerator
132+
//
133+
134+
void
135+
testLuaGenerator()
136+
{
137+
ScopedTempDirectory td("mrdocs-scriptgen");
138+
BOOST_TEST(td);
139+
std::string const script = files::appendPath(td.path(), "g.lua");
140+
writeFile(script, luaIndex);
141+
std::string const outDir = files::appendPath(td.path(), "out");
142+
OutputSink sink(outDir);
143+
144+
Expected<void> result = runLuaGenerator(makeCorpus(), script, sink);
145+
BOOST_TEST(result.has_value());
146+
Expected<std::string> got =
147+
files::getFileText(files::appendPath(outDir, "search-index.json"));
148+
BOOST_TEST(got.has_value());
149+
if (got)
150+
{
151+
BOOST_TEST(*got == expectedJson);
152+
}
153+
}
154+
155+
void
156+
testJsGenerator()
157+
{
158+
ScopedTempDirectory td("mrdocs-scriptgen");
159+
BOOST_TEST(td);
160+
std::string const script = files::appendPath(td.path(), "g.js");
161+
writeFile(script, jsIndex);
162+
std::string const outDir = files::appendPath(td.path(), "out");
163+
OutputSink sink(outDir);
164+
165+
Expected<void> result = runJsGenerator(makeCorpus(), script, sink);
166+
BOOST_TEST(result.has_value());
167+
Expected<std::string> got =
168+
files::getFileText(files::appendPath(outDir, "search-index.json"));
169+
BOOST_TEST(got.has_value());
170+
if (got)
171+
{
172+
BOOST_TEST(*got == expectedJson);
173+
}
174+
}
175+
176+
void
177+
testLuaReadsMissingFieldAsNil()
178+
{
179+
// A symbol object without a `name` field: `get("name")` yields
180+
// `Undefined`, which Lua must marshal as `nil` rather than abort.
181+
// The global namespace has no name, so a real corpus hits this.
182+
dom::Object noName;
183+
noName.set("_id", std::string("0009"));
184+
dom::Array symbols;
185+
symbols.emplace_back(dom::Value(std::move(noName)));
186+
dom::Object corpusObj;
187+
corpusObj.set("symbols", std::move(symbols));
188+
dom::Value const corpus(std::move(corpusObj));
189+
190+
ScopedTempDirectory td("mrdocs-scriptgen");
191+
BOOST_TEST(td);
192+
std::string const script = files::appendPath(td.path(), "g.lua");
193+
writeFile(script, R"LUA(
194+
return function(corpus, output)
195+
local s = corpus.symbols[1]
196+
output.write("out.txt", "name=" .. (s.name or "NONE"))
197+
end
198+
)LUA");
199+
std::string const outDir = files::appendPath(td.path(), "out");
200+
OutputSink sink(outDir);
201+
202+
Expected<void> result = runLuaGenerator(corpus, script, sink);
203+
BOOST_TEST(result.has_value());
204+
Expected<std::string> got =
205+
files::getFileText(files::appendPath(outDir, "out.txt"));
206+
BOOST_TEST(got.has_value());
207+
if (got)
208+
{
209+
BOOST_TEST(*got == "name=NONE");
210+
}
211+
}
212+
213+
void
214+
testMissingGenerateIsError()
215+
{
216+
ScopedTempDirectory td("mrdocs-scriptgen");
217+
BOOST_TEST(td);
218+
std::string const script = files::appendPath(td.path(), "empty.lua");
219+
writeFile(script, "-- this script defines no generate function\n");
220+
OutputSink sink(files::appendPath(td.path(), "out"));
221+
// A generator must define `generate`; its absence is an error.
222+
BOOST_TEST(!runLuaGenerator(makeCorpus(), script, sink).has_value());
223+
}
224+
225+
//
226+
// discoverScriptGenerators
227+
//
228+
229+
void
230+
testDiscoveryRegistersScriptGenerator()
231+
{
232+
ScopedTempDirectory td("mrdocs-scriptgen-disc");
233+
BOOST_TEST(td);
234+
// Lay out <addons>/generator/<id>/ with a script manifest. The id
235+
// is unusual so it does not collide with the process-global
236+
// registry shared across the test binary.
237+
std::string const id = "mrdocs-script-generator-selftest";
238+
std::string const genDir =
239+
files::appendPath(td.path(), "generator", id);
240+
BOOST_TEST(files::createDirectory(genDir).has_value());
241+
writeFile(
242+
files::appendPath(genDir, "mrdocs-generator.yml"),
243+
"script: g.lua\n");
244+
writeFile(files::appendPath(genDir, "g.lua"), luaIndex);
245+
246+
Config::Settings settings;
247+
settings.addons = std::string(td.path());
248+
BOOST_TEST(discoverScriptGenerators(settings).has_value());
249+
BOOST_TEST(findGenerator(id) != nullptr);
250+
}
251+
252+
void
253+
run()
254+
{
255+
testSinkWritesUnderRoot();
256+
testSinkRejectsAbsolutePath();
257+
testSinkRejectsEscape();
258+
testLuaGenerator();
259+
testJsGenerator();
260+
testLuaReadsMissingFieldAsNil();
261+
testMissingGenerateIsError();
262+
testDiscoveryRegistersScriptGenerator();
263+
}
264+
};
265+
266+
TEST_SUITE(
267+
ScriptGeneratorTest,
268+
"clang.mrdocs.script.ScriptGenerator");
269+
270+
} // namespace mrdocs::script

0 commit comments

Comments
 (0)