1 /*
2  * Silly is a test runner for the D programming language
3  *
4  * Report bugs and propose new features in project's repository: https://gitlab.com/AntonMeep/silly
5  */
6 
7 /* SPDX-License-Identifier: ISC */
8 /* Copyright (c) 2018-2019, Anton Fediushin */
9 
10 module silly;
11 
12 version (unittest)  : static if (!__traits(compiles, () {
13         static import dub_test_root;
14     }))
15 {
16     static assert(false,
17             "Couldn't find 'dub_test_root'. Make sure you are running tests with `dub test`");
18 }
19 else
20 {
21     static import dub_test_root;
22 }
23 
24 import core.time : Duration, MonoTime;
25 import std.ascii : newline;
26 import std.stdio : stdout;
27 
28 shared static this()
29 {
30     import core.runtime : Runtime, UnitTestResult;
31     import std.getopt : getopt;
32     import std.parallelism : TaskPool, totalCPUs;
33 
34     Runtime.extendedModuleUnitTester = () {
35         bool verbose;
36         shared ulong passed, failed;
37         uint threads;
38         string include, exclude;
39 
40         auto args = Runtime.args;
41         auto getoptResult = args.getopt("no-colours",
42                 "Disable colours", &noColours, "t|threads",
43                 "Number of worker threads. 0 to auto-detect (default)", &threads, "i|include",
44                 "Run tests if their name matches specified regular expression",
45                 &include, "e|exclude",
46                 "Skip tests if their name matches specified regular expression", &exclude, "v|verbose",
47                 "Show verbose output (full stack traces and durations)", &verbose,);
48 
49         if (getoptResult.helpWanted)
50         {
51             import std.string : leftJustifier;
52 
53             stdout.writefln("Usage:%1$s\tdub test -- <options>%1$s%1$sOptions:", newline);
54 
55             foreach (option; getoptResult.options)
56                 stdout.writefln("  %s\t%s\t%s", option.optShort,
57                         option.optLong.leftJustifier(20), option.help);
58 
59             return UnitTestResult(0, 0, false, false);
60         }
61 
62         if (!threads)
63             threads = totalCPUs;
64 
65         Console.init;
66 
67         Test[] tests;
68 
69         // Test discovery
70         foreach (m; dub_test_root.allModules)
71         {
72             import std.traits : fullyQualifiedName;
73 
74             static if (__traits(isModule, m))
75             {
76                 alias module_ = m;
77             }
78             else
79             {
80                 import std.meta : Alias;
81 
82                 // For cases when module contains member of the same name
83                 alias module_ = Alias!(__traits(parent, m));
84             }
85 
86             // unittests in the module
87             foreach (test; __traits(getUnitTests, module_))
88                 tests ~= Test(fullyQualifiedName!test, getTestName!test, &test);
89 
90             // unittests in structs and classes
91             foreach (memberName; __traits(derivedMembers, module_))
92             {
93                 static if (__traits(compiles, __traits(getMember, module_, memberName)))
94                 {
95                     alias member = __traits(getMember, module_, memberName);
96                     // pragma(msg, module_.stringof, " has memberName ", memberName.stringof);
97                     static if (__traits(compiles, __traits(parent, member)))
98                     {
99                         alias parent = __traits(parent, member);
100                         // TODO fails: !__traits(isTemplate,  __traits(getMember, module_, memberName)) &&
101                         static if (__traits(isSame, parent, module_)
102                                 && __traits(compiles, __traits(getUnitTests, member)))
103                         {
104                             alias unittests = __traits(getUnitTests, member);
105                             foreach (test; unittests)
106                             {
107                                 tests ~= Test(fullyQualifiedName!test, getTestName!test, &test);
108                             }
109                         }
110                     }
111                 }
112             }
113         }
114 
115         auto started = MonoTime.currTime;
116         with (new TaskPool(threads - 1))
117         {
118             import core.atomic : atomicOp;
119             import std.regex : matchFirst;
120 
121             foreach (test; parallel(tests))
122             {
123                 if ((!include && !exclude) || (include
124                         && !(test.fullName ~ " " ~ test.testName).matchFirst(include).empty) || (exclude
125                         && (test.fullName ~ " " ~ test.testName).matchFirst(exclude).empty))
126                 {
127                     auto result = test.executeTest;
128                     writeResult(result, verbose);
129                     atomicOp!"+="(result.succeed ? passed : failed, 1UL);
130                 }
131             }
132             finish(true);
133         }
134         auto ended = MonoTime.currTime;
135 
136         stdout.writeln;
137         stdout.writefln("%s: %s passed, %s failed in %d ms", Console.emphasis("Summary"),
138                 Console.colour(passed, Colour.ok), Console.colour(failed,
139                     failed ? Colour.achtung : Colour.none), (ended - started).total!"msecs",);
140 
141         return UnitTestResult(passed + failed, passed, false, false);
142     };
143 }
144 
145 void writeResult(TestResult result, in bool verbose)
146 {
147     import std.format : formattedWrite;
148     import std.algorithm.searching : canFind;
149     import std.range : drop;
150     import std.string : lastIndexOf, lineSplitter;
151 
152     auto writer = stdout.lockingTextWriter;
153 
154     writer.formattedWrite(" %s %s %s", result.succeed ? Console.colour("✓",
155             Colour.ok) : Console.colour("✗", Colour.achtung),
156             Console.emphasis(result.test.fullName[0 .. result.test.fullName.lastIndexOf(
157                     '.')].truncateName(verbose)), result.test.testName,);
158 
159     if (verbose)
160         writer.formattedWrite(" (%.3f ms)", (cast(real) result.duration.total!"usecs") / 10.0f ^^ 3);
161 
162     writer.put(newline);
163 
164     foreach (th; result.thrown)
165     {
166         writer.formattedWrite("    %s thrown from %s on line %d: %s%s",
167                 th.type, th.file, th.line, th.message.lineSplitter.front, newline,);
168         foreach (line; th.message.lineSplitter.drop(1))
169             writer.formattedWrite("      %s%s", line, newline);
170 
171         writer.formattedWrite("    --- Stack trace ---%s", newline);
172         if (verbose)
173         {
174             foreach (line; th.info)
175                 writer.formattedWrite("    %s%s", line, newline);
176         }
177         else
178         {
179             for (size_t i = 0; i < th.info.length && !th.info[i].canFind(__FILE__);
180                     ++i)
181                 writer.formattedWrite("    %s%s", th.info[i], newline);
182         }
183     }
184 }
185 
186 TestResult executeTest(Test test)
187 {
188     import core.exception : AssertError, OutOfMemoryError;
189 
190     auto ret = TestResult(test);
191     auto started = MonoTime.currTime;
192 
193     try
194     {
195         scope (exit)
196             ret.duration = MonoTime.currTime - started;
197         test.ptr();
198         ret.succeed = true;
199     }
200     catch (Throwable t)
201     {
202         if (!(cast(Exception) t || cast(AssertError) t))
203             throw t;
204 
205         foreach (th; t)
206         {
207             immutable(string)[] trace;
208             try
209             {
210                 foreach (i; th.info)
211                     trace ~= i.idup;
212             }
213             catch (OutOfMemoryError)
214             { // TODO: Actually fix a bug instead of this workaround
215                 trace ~= "<silly error> Failed to get stack trace, see https://gitlab.com/AntonMeep/silly/issues/31";
216             }
217 
218             ret.thrown ~= Thrown(typeid(th).name, th.message.idup, th.file, th.line, trace);
219         }
220     }
221 
222     return ret;
223 }
224 
225 struct Test
226 {
227     string fullName, testName;
228     void function() ptr;
229 }
230 
231 struct TestResult
232 {
233     Test test;
234     bool succeed;
235     Duration duration;
236     immutable(Thrown)[] thrown;
237 }
238 
239 struct Thrown
240 {
241     string type, message, file;
242     size_t line;
243     immutable(string)[] info;
244 }
245 
246 __gshared bool noColours;
247 
248 enum Colour
249 {
250     none,
251     ok = 32,
252     achtung = 31,
253 }
254 
255 static struct Console
256 {
257     static void init()
258     {
259         if (noColours)
260         {
261             return;
262         }
263         else
264         {
265             version (Posix)
266             {
267                 import core.sys.posix.unistd;
268 
269                 noColours = isatty(STDOUT_FILENO) == 0;
270             }
271             else version (Windows)
272             {
273                 import core.sys.windows.winbase : GetStdHandle,
274                     STD_OUTPUT_HANDLE, INVALID_HANDLE_VALUE;
275                 import core.sys.windows.wincon : SetConsoleOutputCP, GetConsoleMode, SetConsoleMode;
276                 import core.sys.windows.windef : DWORD;
277                 import core.sys.windows.winnls : CP_UTF8;
278 
279                 SetConsoleOutputCP(CP_UTF8);
280 
281                 auto hOut = GetStdHandle(STD_OUTPUT_HANDLE);
282                 DWORD originalMode;
283 
284                 // TODO: 4 stands for ENABLE_VIRTUAL_TERMINAL_PROCESSING which should be
285                 // in druntime v2.082.0
286                 noColours = hOut == INVALID_HANDLE_VALUE || !GetConsoleMode(hOut,
287                         &originalMode) || !SetConsoleMode(hOut, originalMode | 4);
288             }
289         }
290     }
291 
292     static string colour(T)(T t, Colour c = Colour.none)
293     {
294         import std.conv : text;
295 
296         return noColours ? text(t) : text("\033[", cast(int) c, "m", t, "\033[m");
297     }
298 
299     static string emphasis(string s)
300     {
301         return noColours ? s : "\033[1m" ~ s ~ "\033[m";
302     }
303 }
304 
305 string getTestName(alias test)()
306 {
307     string name = __traits(identifier, test);
308     foreach (attribute; __traits(getAttributes, test))
309     {
310         static if (is(typeof(attribute) : string))
311         {
312             name = attribute;
313             break;
314         }
315     }
316     return name;
317 }
318 
319 string truncateName(string s, bool verbose = false)
320 {
321     import std.algorithm.comparison : max;
322     import std.string : indexOf;
323     return s.length > 30 && !verbose ? s[max(s.indexOf('.', s.length - 30), s.length - 30) .. $] : s;
324 }