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