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