1 /**
2 Copyright: Copyright (c) 2019, Joakim Brännström. All rights reserved.
3 License: MPL-2
4 Author: Joakim Brännström (joakim.brannstrom@gmx.com)
5 
6 This Source Code Form is subject to the terms of the Mozilla Public License,
7 v.2.0. If a copy of the MPL was not distributed with this file, You can obtain
8 one at http://mozilla.org/MPL/2.0/.
9 
10 A runner that can execute all the users test commands. These can be either
11 manually specified or automatically detected.
12 */
13 module dextool.plugin.mutate.backend.test_mutant.test_cmd_runner;
14 
15 import logger = std.experimental.logger;
16 import std.algorithm : filter, map, joiner;
17 import std.array : appender, Appender, empty, array;
18 import std.datetime : Duration, dur, Clock;
19 import std.exception : collectException;
20 import std.format : format;
21 import std.parallelism : TaskPool, Task, task;
22 import std.random : randomCover;
23 import std.range : take;
24 import std.typecons : Tuple;
25 
26 import proc;
27 import my.path : AbsolutePath, Path;
28 
29 import dextool.plugin.mutate.type : ShellCommand;
30 
31 version (unittest) {
32     import unit_threaded.assertions;
33 }
34 
35 @safe:
36 
37 struct TestRunner {
38     private {
39         alias TestTask = Task!(spawnRunTest, string[], Duration, string[string], Signal);
40         TaskPool pool;
41         Duration timeout_;
42 
43         Signal earlyStopSignal;
44 
45         /// Commands that execute the test cases.
46         alias TestCmd = Tuple!(ShellCommand, "cmd", double, "kills");
47         TestCmd[] commands;
48         long nrOfRuns;
49     }
50 
51     /// Environment to set when executing either binaries or the command.
52     string[string] env;
53 
54     static auto make(int poolSize) {
55         return TestRunner(poolSize);
56     }
57 
58     this(int poolSize_) {
59         this.poolSize(poolSize_);
60         this.earlyStopSignal = new Signal(false);
61     }
62 
63     ~this() {
64         pool.stop;
65     }
66 
67     /** Stop executing tests as soon as one detects a failure.
68      *
69      * This lose some information about the test cases but mean that mutation
70      * testing overall is executing faster.
71      */
72     void useEarlyStop(bool v) @safe nothrow {
73         this.earlyStopSignal = new Signal(v);
74     }
75 
76     bool empty() @safe pure nothrow const @nogc {
77         return commands.length == 0;
78     }
79 
80     void poolSize(const int s) @safe {
81         if (pool !is null) {
82             pool.stop;
83         }
84         if (s == 0) {
85             pool = new TaskPool;
86         } else {
87             pool = new TaskPool(s);
88         }
89         pool.isDaemon = true;
90     }
91 
92     void timeout(Duration timeout) pure nothrow @nogc {
93         this.timeout_ = timeout;
94     }
95 
96     void put(ShellCommand sh) pure nothrow {
97         commands ~= TestCmd(sh, 0);
98     }
99 
100     void put(ShellCommand[] sh) pure nothrow {
101         foreach (a; sh)
102             commands ~= TestCmd(a, 0);
103     }
104 
105     TestResult run(string[string] localEnv = null) {
106         return this.run(timeout_, localEnv);
107     }
108 
109     TestResult run(Duration timeout, string[string] localEnv = null) {
110         import core.thread : Thread;
111         import core.time : dur;
112         import std.range : enumerate;
113 
114         static TestTask* findDone(ref TestTask*[] tasks) {
115             bool found;
116             size_t idx;
117             foreach (t; tasks.enumerate.filter!(a => a.value.done)) {
118                 idx = t.index;
119                 found = true;
120                 break;
121             }
122 
123             if (found) {
124                 auto t = tasks[idx];
125                 tasks[idx] = tasks[$ - 1];
126                 tasks = tasks[0 .. $ - 1];
127                 return t;
128             }
129             return null;
130         }
131 
132         void processDone(TestTask* t, ref TestResult result, ref Appender!(DrainElement[]) output) {
133             auto res = t.yieldForce;
134             final switch (res.status) {
135             case RunResult.Status.normal:
136                 if (result.status == TestResult.Status.passed && res.exitStatus != 0) {
137                     result.status = TestResult.Status.failed;
138                 }
139                 if (res.exitStatus != 0) {
140                     incrCmdKills(res.cmd);
141                 }
142                 output.put(res.output);
143                 break;
144             case RunResult.Status.timeout:
145                 result.status = TestResult.Status.timeout;
146                 break;
147             case RunResult.Status.error:
148                 result.status = TestResult.Status.error;
149                 break;
150             }
151         }
152 
153         auto env_ = env;
154         foreach (kv; localEnv.byKeyValue) {
155             env_[kv.key] = kv.value;
156         }
157 
158         const reorderWhen = 10;
159 
160         scope (exit)
161             nrOfRuns++;
162         if (nrOfRuns == 0) {
163             commands = commands.randomCover.array;
164         } else if (nrOfRuns % reorderWhen == 0) {
165             // use a forget factor to make the order re-arrange over time
166             // if the "best" test case change.
167             foreach (ref a; commands) {
168                 a.kills = a.kills * 0.9;
169             }
170 
171             import std.algorithm : sort;
172 
173             // use those that kill the most first
174             commands = commands.sort!((a, b) => a.kills > b.kills).array;
175             logger.infof("Update test command order: %(%s, %)",
176                     commands.take(reorderWhen).map!(a => format("%s:%.2f", a.cmd, a.kills)));
177         }
178 
179         earlyStopSignal.reset;
180         TestTask*[] tasks = startTests(timeout, env_);
181         TestResult rval;
182         auto output = appender!(DrainElement[])();
183         while (!tasks.empty) {
184             auto t = findDone(tasks);
185             if (t !is null) {
186                 processDone(t, rval, output);
187                 .destroy(t);
188             }
189             () @trusted { Thread.sleep(20.dur!"msecs"); }();
190         }
191 
192         rval.output = output.data;
193         return rval;
194     }
195 
196     private auto startTests(Duration timeout, string[string] env) @trusted {
197         auto tasks = appender!(TestTask*[])();
198 
199         foreach (c; commands) {
200             auto t = task!spawnRunTest(c.cmd.value, timeout, env, earlyStopSignal);
201             tasks.put(t);
202             pool.put(t);
203         }
204         return tasks.data;
205     }
206 
207     /// Find the test command and update its kill counter.
208     private void incrCmdKills(string[] cmd) {
209         foreach (ref a; commands) {
210             if (a.cmd.value == cmd) {
211                 a.kills++;
212                 break;
213             }
214         }
215     }
216 }
217 
218 /// The result of running the tests.
219 struct TestResult {
220     enum Status {
221         /// All test commands exited with exit status zero.
222         passed,
223         /// At least one test command indicated that an error where found by exit status not zero.
224         failed,
225         /// At least one test command timed out.
226         timeout,
227         /// Something happend when the test command executed thus the result should not be used.
228         error,
229     }
230 
231     Status status;
232 
233     /// Output from all the test binaries and command.
234     DrainElement[] output;
235 }
236 
237 /// Finds all executables in a directory tree.
238 string[] findExecutables(AbsolutePath root) @trusted {
239     import core.sys.posix.sys.stat;
240     import std.file : getAttributes;
241     import std.file : dirEntries, SpanMode;
242     import my.file : isExecutable;
243 
244     auto app = appender!(string[])();
245     foreach (f; dirEntries(root, SpanMode.breadth).filter!(a => a.isFile)
246             .filter!(a => isExecutable(Path(a.name)))) {
247         app.put([f.name]);
248     }
249 
250     return app.data;
251 }
252 
253 RunResult spawnRunTest(string[] cmd, Duration timeout, string[string] env, Signal earlyStop) @trusted nothrow {
254     import std.algorithm : copy;
255     static import std.process;
256 
257     RunResult rval;
258     rval.cmd = cmd;
259 
260     if (earlyStop.isActive) {
261         debug logger.tracef("Early stop detected. Skipping %s (%s)", cmd,
262                 Clock.currTime).collectException;
263         return rval;
264     }
265 
266     try {
267         auto p = pipeProcess(cmd, std.process.Redirect.all, env).sandbox.timeout(timeout).scopeKill;
268         auto output = appender!(DrainElement[])();
269         foreach (a; p.process.drain) {
270             if (!a.empty) {
271                 output.put(a);
272             }
273             if (earlyStop.isActive) {
274                 debug logger.tracef("Early stop detected. Stopping %s (%s)", cmd, Clock.currTime);
275                 p.kill;
276                 break;
277             }
278         }
279 
280         if (p.timeoutTriggered) {
281             rval.status = RunResult.Status.timeout;
282         }
283 
284         rval.exitStatus = p.wait;
285         rval.output = output.data;
286     } catch (Exception e) {
287         logger.warning(e.msg).collectException;
288         rval.status = RunResult.Status.error;
289     }
290 
291     if (rval.exitStatus != 0) {
292         earlyStop.activate;
293         debug logger.tracef("Early stop triggered by %s (%s)", rval.cmd,
294                 Clock.currTime).collectException;
295     }
296 
297     return rval;
298 }
299 
300 private:
301 
302 struct RunResult {
303     enum Status {
304         /// the test command successfully executed.
305         normal,
306         /// Something happend when the test command executed thus the result should not be used.
307         error,
308         /// The test command timed out.
309         timeout,
310     }
311 
312     /// The command that where executed.
313     string[] cmd;
314 
315     Status status;
316     ///
317     int exitStatus;
318     ///
319     DrainElement[] output;
320 }
321 
322 string makeUnittestScript(string script, string file = __FILE__, uint line = __LINE__) {
323     import core.sys.posix.sys.stat;
324     import std.file : getAttributes, setAttributes, thisExePath;
325     import std.stdio : File;
326     import std.path : baseName;
327     import std.conv : to;
328 
329     immutable fname = thisExePath ~ "_" ~ script ~ file.baseName ~ line.to!string ~ ".sh";
330 
331     File(fname, "w").writeln(`#!/bin/bash
332 echo $1
333 if [[ "$3" = "timeout" ]]; then
334     sleep 10m
335 fi
336 exit $2`);
337     setAttributes(fname, getAttributes(fname) | S_IXUSR | S_IXGRP | S_IXOTH);
338     return fname;
339 }
340 
341 version (unittest) {
342     import core.time : dur;
343     import std.algorithm : count;
344     import std.array : array, empty;
345     import std.file : remove, exists;
346     import std.string : strip;
347 }
348 
349 @("shall collect the result from the test commands when running them")
350 unittest {
351     immutable script = makeUnittestScript("script_");
352     scope (exit)
353         () {
354         if (exists(script))
355             remove(script);
356     }();
357 
358     auto runner = TestRunner.make(0);
359     runner.put([script, "foo", "0"].ShellCommand);
360     runner.put([script, "foo", "0"].ShellCommand);
361     auto res = runner.run(5.dur!"seconds");
362 
363     res.output.filter!(a => !a.empty).count.shouldEqual(2);
364     res.output.filter!(a => a.byUTF8.array.strip == "foo").count.shouldEqual(2);
365     res.status.shouldEqual(TestResult.Status.passed);
366 }
367 
368 @("shall set the status to failed when one of test command fail")
369 unittest {
370     immutable script = makeUnittestScript("script_");
371     scope (exit)
372         () {
373         if (exists(script))
374             remove(script);
375     }();
376 
377     auto runner = TestRunner.make(0);
378     runner.put([script, "foo", "0"].ShellCommand);
379     runner.put([script, "foo", "1"].ShellCommand);
380     auto res = runner.run(5.dur!"seconds");
381 
382     res.output.filter!(a => !a.empty).count.shouldEqual(2);
383     res.output.filter!(a => a.byUTF8.array.strip == "foo").count.shouldEqual(2);
384     res.status.shouldEqual(TestResult.Status.failed);
385 }
386 
387 @("shall set the status to timeout when one of the tests commands reach the timeout limit")
388 unittest {
389     immutable script = makeUnittestScript("script_");
390     scope (exit)
391         () {
392         if (exists(script))
393             remove(script);
394     }();
395 
396     auto runner = TestRunner.make(0);
397     runner.put([script, "foo", "0"].ShellCommand);
398     runner.put([script, "foo", "0", "timeout"].ShellCommand);
399     auto res = runner.run(1.dur!"seconds");
400 
401     res.output.filter!(a => a.byUTF8.array.strip == "foo").count.shouldEqual(1);
402     res.status.shouldEqual(TestResult.Status.timeout);
403 }
404 
405 /// Thread safe signal.
406 class Signal {
407     import core.atomic : atomicLoad, atomicStore;
408 
409     shared int state;
410     immutable bool isUsed;
411 
412     this(bool isUsed) @safe pure nothrow @nogc {
413         this.isUsed = isUsed;
414     }
415 
416     bool isActive() @safe nothrow @nogc const {
417         if (!isUsed)
418             return false;
419 
420         auto local = atomicLoad(state);
421         return local != 0;
422     }
423 
424     void activate() @safe nothrow @nogc {
425         atomicStore(state, 1);
426     }
427 
428     void reset() @safe nothrow @nogc {
429         atomicStore(state, 0);
430     }
431 }