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 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, &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, &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 	writer.put(newline);
150 
151 	foreach(th; result.thrown) {
152 		writer.formattedWrite("    %s thrown from %s on line %d: %s%s",
153 			th.type,
154 			th.file,
155 			th.line,
156 			th.message.lineSplitter.front,
157 			newline,
158 		);
159 		foreach(line; th.message.lineSplitter.drop(1))
160 			writer.formattedWrite("      %s%s", line, newline);
161 
162 		writer.formattedWrite("    --- Stack trace ---%s", newline);
163 		if(verbose) {
164 			foreach(line; th.info)
165 				writer.formattedWrite("    %s%s", line, newline);
166 		} else {
167 			for(size_t i = 0; i < th.info.length && !th.info[i].canFind(__FILE__); ++i)
168 				writer.formattedWrite("    %s%s", th.info[i], newline);
169 		}
170 	}
171 }
172 
173 TestResult executeTest(Test test) {
174 	import core.exception : AssertError, OutOfMemoryError;
175 	auto ret = TestResult(test);
176 	auto started = MonoTime.currTime;
177 
178 	try {
179 		scope(exit) ret.duration = MonoTime.currTime - started;
180 		test.ptr();
181 		ret.succeed = true;
182 	} catch(Throwable t) {
183 		if(!(cast(Exception) t || cast(AssertError) t))
184 			throw t;
185 
186 		foreach(th; t) {
187 			immutable(string)[] trace;
188 			try {
189 				foreach(i; th.info)
190 					trace ~= i.idup;
191 			} catch(OutOfMemoryError) { // TODO: Actually fix a bug instead of this workaround
192 				trace ~= "<silly error> Failed to get stack trace, see https://gitlab.com/AntonMeep/silly/issues/31";
193 			}
194 
195 			ret.thrown ~= Thrown(typeid(th).name, th.message.idup, th.file, th.line, trace);
196 		}
197 	}
198 
199 	return ret;
200 }
201 
202 struct Test {
203 	string fullName,
204 		   testName;
205 
206 	void function() ptr;
207 }
208 
209 struct TestResult {
210 	Test test;
211 	bool succeed;
212 	Duration duration;
213 
214 	immutable(Thrown)[] thrown;
215 }
216 
217 struct Thrown {
218 	string type,
219 		   message,
220 		   file;
221 	size_t line;
222 	immutable(string)[] info;
223 }
224 
225 __gshared bool noColours;
226 
227 enum Colour {
228 	none,
229 	ok = 32,
230 	achtung = 31,
231 }
232 
233 static struct Console {
234 	static void init() {
235 		if(noColours) {
236 			return;
237 		} else {
238 			version(Posix) {
239 				import core.sys.posix.unistd;
240 				noColours = isatty(STDOUT_FILENO) == 0;
241 			} else version(Windows) {
242 				import core.sys.windows.winbase : GetStdHandle, STD_OUTPUT_HANDLE, INVALID_HANDLE_VALUE;
243 				import core.sys.windows.wincon  : SetConsoleOutputCP, GetConsoleMode, SetConsoleMode;
244 				import core.sys.windows.windef  : DWORD;
245 				import core.sys.windows.winnls  : CP_UTF8;
246 
247 				SetConsoleOutputCP(CP_UTF8);
248 
249 				auto hOut = GetStdHandle(STD_OUTPUT_HANDLE);
250 				DWORD originalMode;
251 
252 				// TODO: 4 stands for ENABLE_VIRTUAL_TERMINAL_PROCESSING which should be
253 				// in druntime v2.082.0
254 				noColours = hOut == INVALID_HANDLE_VALUE           ||
255 							!GetConsoleMode(hOut, &originalMode)   ||
256 							!SetConsoleMode(hOut, originalMode | 4);
257 			}
258 		}
259 	}
260 
261 	static string colour(T)(T t, Colour c = Colour.none) {
262 		import std.conv : text;
263 
264 		return noColours ? text(t) : text("\033[", cast(int) c, "m", t, "\033[m");
265 	}
266 
267 	static string emphasis(string s) {
268 		return noColours ? s : "\033[1m" ~ s ~ "\033[m";
269 	}
270 }
271 
272 string getTestName(alias test)() {
273 	string name = __traits(identifier, test);
274 
275 	foreach(attribute; __traits(getAttributes, test)) {
276 		static if(is(typeof(attribute) : string)) {
277 			name = attribute;
278 			break;
279 		}
280 	}
281 
282 	return name;
283 }
284 
285 string truncateName(string s, bool verbose = false) {
286 	import std.algorithm : max;
287 	import std.string    : indexOf;
288 	return s.length > 30 && !verbose
289 		? s[max(s.indexOf('.', s.length - 30), s.length - 30) .. $]
290 		: s;
291 }