1 /+dub.sdl:
2     name "test"
3     dependency "jcli" path="../"
4 +/
5 module test;
6 
7 import std, jcli;
8 
9 /++ DATA TYPES ++/
10 struct TestCase
11 {
12     string                  folder;
13     string                  params;
14     string[]                cleanupFiles;
15     Nullable!int            expectedStatus;
16     Nullable!(Regex!char)   outputRegex;
17     bool                    allowedToFail;
18 }
19 
20 struct TestCaseBuilder
21 {
22     TestCase testCase;
23 
24     TestCaseBuilder inFolder(string folder)
25     {
26         this.testCase.folder = folder;
27         return this;
28     }
29 
30     TestCaseBuilder withParams(string params)
31     {
32         this.testCase.params = params;
33         return this;
34     }
35 
36     TestCaseBuilder expectStatusToBe(int status)
37     {
38         this.testCase.expectedStatus = status;
39         return this;
40     }
41 
42     TestCaseBuilder expectOutputToMatch(string regexString)
43     {
44         this.testCase.outputRegex = regex(regexString);
45         return this;
46     }
47 
48     TestCaseBuilder cleanup(string fileName)
49     {
50         this.testCase.cleanupFiles ~= fileName;
51         return this;
52     }
53 
54     TestCaseBuilder allowToFail()
55     {
56         this.testCase.allowedToFail = true;
57         return this;
58     }
59 
60     TestCase finish()
61     {
62         return this.testCase;
63     }
64 }
65 TestCaseBuilder testCase(){ return TestCaseBuilder(); }
66 
67 struct TestResult
68 {
69     bool     passed;
70     string[] failedReasons;
71     TestCase testCase;
72 
73     int      buildStatus;
74     int      runStatus;
75     string   buildOut;
76     string   runOut;
77 }
78 
79 /++ CONFIGURATION ++/
80 auto TEST_CASES = 
81 [
82     testCase().inFolder         ("./00-basic-usage-default-command/")
83               .withParams       ("20")
84               .expectStatusToBe (0)
85               .finish           (),
86     testCase().inFolder         ("./00-basic-usage-default-command/")
87               .withParams       ("20 --reverse")
88               .expectStatusToBe (128)
89               .finish           (),
90     
91     testCase().inFolder         ("./01-named-sub-commands/")
92               .withParams       ("return 0")
93               .expectStatusToBe (0)
94               .finish           (),
95     testCase().inFolder         ("./01-named-sub-commands/")
96               .withParams       ("r 128")
97               .expectStatusToBe (128)
98               .finish           (),
99 
100     testCase().inFolder         ("./02-shorthand-longhand-args/")
101               .withParams       ("return --code 0")
102               .expectStatusToBe (0)
103               .finish           (),
104     testCase().inFolder         ("./02-shorthand-longhand-args/")
105               .withParams       ("r -c=128")
106               .expectStatusToBe (128)
107               .finish           (),
108 
109     // Class inheritence is broken, but I don't really think I can fix it without compiler changes.
110     testCase().inFolder         ("./03-inheritence-base-commands/")
111               .withParams       ("add 1 2")
112               .expectStatusToBe (3)
113               .allowToFail      ()
114               .finish           (),
115     testCase().inFolder         ("./03-inheritence-base-commands/")
116               .withParams       ("add 1 2 --offset=7")
117               .expectStatusToBe (10)
118               .allowToFail      ()
119               .finish           (),
120 
121     testCase().inFolder         ("./04-custom-arg-binders/")
122               .withParams       ("./dub.sdl")
123               .expectStatusToBe (0)
124               .finish           (),
125     testCase().inFolder         ("./04-custom-arg-binders/")
126               .withParams       ("./lalaland.txt")
127               .expectStatusToBe (-1)
128               .finish           (),
129 
130     testCase().inFolder         ("./05-built-in-binders/")
131               .withParams       ("echo -b -i 2 -f 2.2 -s Hola -e red")
132               .expectStatusToBe (0)
133               .finish           (),
134 
135     testCase().inFolder         ("./06-result-with-fail-code/")
136               .withParams       ("abc")
137               .expectStatusToBe (100)
138               .finish           (),
139     testCase().inFolder         ("./06-result-with-fail-code/")
140               .withParams       ("0")
141               .expectStatusToBe (102)
142               .finish           (),
143     testCase().inFolder         ("./06-result-with-fail-code/")
144               .withParams       ("3")
145               .expectStatusToBe (101)
146               .finish           (),
147     testCase().inFolder         ("./06-result-with-fail-code/")
148               .withParams       ("1")
149               .expectStatusToBe (0)
150               .finish           (),
151 
152     testCase().inFolder             ("./08-arg-binder-validation")
153               .withParams           ("20 69")
154               .expectStatusToBe     (0)
155               .finish               (),
156     testCase().inFolder             ("./08-arg-binder-validation")
157               .withParams           ("69 69")
158               .expectStatusToBe     (-1)
159               .expectOutputToMatch  ("Expected number to be even")
160               .finish               (),
161     testCase().inFolder             ("./08-arg-binder-validation")
162               .withParams           ("20 20")
163               .expectStatusToBe     (-1)
164               .expectOutputToMatch  ("Expected number to be odd")
165               .finish               (),
166 
167     testCase().inFolder             ("./09-raw-unparsed-arg-list")
168               .withParams           ("echo -- Some args")
169               .expectStatusToBe     (0)
170               .expectOutputToMatch  (`Running command 'echo' with arguments \["Some", "args"\]`)
171               .finish               (),
172     testCase().inFolder             ("./09-raw-unparsed-arg-list")
173               .withParams           ("noarg")
174               .expectStatusToBe     (0)
175               .expectOutputToMatch  (`Running command 'noarg' with arguments \[\]`)
176               .finish               (),
177 
178     testCase().inFolder             ("./10-argument-options")
179               .withParams           ("sensitive --abc 2")
180               .expectStatusToBe     (0)
181               .finish               (),
182     testCase().inFolder             ("./10-argument-options")
183               .withParams           ("sensitive --abC 2")
184               .expectStatusToBe     (-1)
185               .finish               (),
186     testCase().inFolder             ("./10-argument-options")
187               .withParams           ("insensitive --abc 2")
188               .expectStatusToBe     (0)
189               .finish               (),
190     testCase().inFolder             ("./10-argument-options")
191               .withParams           ("insensitive --ABC 2")
192               .expectStatusToBe     (0)
193               .finish               (),
194     testCase().inFolder             ("./10-argument-options")
195               .withParams           ("redefine --abc 2")
196               .expectStatusToBe     (2)
197               .finish               (),
198     testCase().inFolder             ("./10-argument-options")
199               .withParams           ("redefine --abc 2 --abc 1")
200               .expectStatusToBe     (1)
201               .finish               (),
202     testCase().inFolder             ("./10-argument-options")
203               .withParams           ("no-redefine --abc 2")
204               .expectStatusToBe     (0)
205               .finish               (),
206     testCase().inFolder             ("./10-argument-options")
207               .withParams           ("no-redefine --abc 2 --abc 1")
208               .expectStatusToBe     (-1)
209               .finish               (),
210 ];
211  
212 /++ MAIN ++/
213 int main(string[] args)
214 {
215     return (new CommandLineInterface!test()).parseAndExecute(args);
216 }
217 
218 /++ COMMANDS ++/
219 @CommandDefault("Runs all test cases")
220 struct DefaultCommand
221 {
222     int onExecute()
223     {
224         writefln("Running %s tests.", "ALL".ansi.fg(Ansi4BitColour.green));
225         const anyfails = runTestSet(TEST_CASES);
226 
227         return anyfails ? -1 : 0;
228     }
229 }
230 
231 @Command("ui", "Runs the test UI")
232 struct UICommand
233 {
234     void onExecute()
235     {
236         auto ui = new UI();
237         ui.run();
238     }
239 }
240 
241 @Command("cleanup", "Runs the cleanup command for all test cases")
242 struct CleanupCommand
243 {
244     void onExecute()
245     {
246         foreach(test; TEST_CASES)
247             runCleanup(test);
248     }
249 }
250 
251 /++ FUNCS ++/
252 bool runTestSet(TestCase[] testSet)
253 {
254     auto results = new TestResult[testSet.length];
255     foreach(i, testCase; testSet)
256         results[i] = testCase.runTest();
257 
258     if(results.any!(r => !r.passed))
259         writefln("\n\nThe following tests %s:", "FAILED".ansi.fg(Ansi4BitColour.red));
260 
261     size_t failedCount;
262     foreach(failed; results.filter!(r => !r.passed))
263     {
264         failedCount++;
265         writefln("\t%s", failed.testCase.to!string.ansi.fg(Ansi4BitColour.cyan));
266 
267         foreach(reason; failed.failedReasons)
268             writefln("\t\t- %s".ansi.fg(Ansi4BitColour.red).to!string, reason);
269     }
270 
271     size_t passedCount = results.length - failedCount;
272     writefln(
273         "\n%s %s, %s %s, %s total tests",
274         passedCount, "PASSED".ansi.fg(Ansi4BitColour.green),
275         failedCount, "FAILED".ansi.fg(Ansi4BitColour.red),
276         results.length
277     );
278 
279     return (failedCount != 0);
280 }
281 
282 TestResult runTest(TestCase testCase)
283 {
284     const    results = getBuildAndTestResults(testCase);
285     auto     passed  = true;
286     string[] reasons;
287 
288     void failIf(bool condition, string reason)
289     {
290         if(!condition)
291             return;
292 
293         passed   = false;
294         reasons ~= reason;
295     }
296 
297     failIf(results[0].status != 0, "Build failed.");
298 
299     // When handling the status code, some terminals allow negative status codes, some don't, so we'll special case expecting
300     // a -1 as expecting -1 or 255.
301     if(testCase.expectedStatus.get(0) != -1)
302         failIf(results[1].status != testCase.expectedStatus.get(0), "Status code is wrong.");
303     else
304         failIf(results[1].status != -1 && results[1].status != 255, "Status code is wrong. (-1 special case)");
305 
306     if(!testCase.outputRegex.isNull)
307         failIf(!results[1].output.matchFirst(testCase.outputRegex.get), "Output doesn't contain a match for the given regex.");
308 
309     if(testCase.allowedToFail)
310     {
311         if(!passed)
312             writeln("Test FAILED (ALLOWED).".ansi.fg(Ansi4BitColour.yellow));
313         passed = true;
314     }
315 
316     if(!passed)
317         writeln("Test FAILED".ansi.fg(Ansi4BitColour.red));
318     else
319         writefln("%s", "Test PASSED".ansi.fg(Ansi4BitColour.green));
320 
321     return TestResult(passed, reasons, testCase, results[0].status, results[1].status, results[0].output, results[1].output);
322 }
323 
324 // [0] = build result, [1] = test result
325 auto getBuildAndTestResults(TestCase testCase)
326 {
327     const CATEGORY_COLOUR = Ansi4BitColour.magenta;
328     const VALUE_COLOUR    = Ansi4BitColour.brightBlue;
329     const RESULT_COLOUR   = Ansi4BitColour.yellow;
330 
331     writefln("");
332     writefln("%s", "[Test Case]".ansi.fg(CATEGORY_COLOUR));
333     writefln("%s: %s", "Folder".ansi.fg(CATEGORY_COLOUR),  testCase.folder.ansi.fg(VALUE_COLOUR));
334     writefln("%s: %s", "Params".ansi.fg(CATEGORY_COLOUR),  testCase.params.ansi.fg(VALUE_COLOUR));
335     writefln("%s: %s", "Status".ansi.fg(CATEGORY_COLOUR),  testCase.expectedStatus.get(0).to!string.ansi.fg(RESULT_COLOUR));
336     writefln("%s: %s", "Regex ".ansi.fg(CATEGORY_COLOUR),  testCase.outputRegex.get(regex("N/A")).to!string.ansi.fg(RESULT_COLOUR));
337 
338     auto cwd = getcwd();
339     chdir(testCase.folder);
340     scope(exit) chdir(cwd);
341 
342     foreach(file; testCase.cleanupFiles.filter!(f => f.exists))
343     {
344         writefln("Cleanup: %s".ansi.fg(Ansi4BitColour.brightBlack).to!string, file);
345         remove(file);
346     }
347 
348     const buildString   = "dub build --compiler=ldc2";
349     const commandString = "\"./test\" " ~ testCase.params;
350     const buildResult   = executeShell(buildString);
351     const result        = executeShell(commandString);
352 
353     writefln("\n%s(status: %s):\n%s", "Build output".ansi.fg(CATEGORY_COLOUR), buildResult.status.to!string.ansi.fg(RESULT_COLOUR), buildResult.output);
354     writefln("%s(status: %s):\n%s",   "Test output".ansi.fg(CATEGORY_COLOUR),  result.status.to!string.ansi.fg(RESULT_COLOUR),      result.output);
355 
356     return [buildResult, result];
357 }
358 
359 void runCleanup(TestCase testCase)
360 {
361     auto cwd = getcwd();
362     chdir(testCase.folder);
363     scope(exit) chdir(cwd);
364     executeShell("dub clean");
365 }
366 
367 final class UI
368 {
369     TextBuffer buffer;
370     Layout layout;
371     size_t selectedTest;
372     size_t buildOffset;
373     size_t buildXOffset;
374     size_t runOffset;
375 
376     static struct Test
377     {
378         enum State
379         {
380             notRan,
381             success,
382             failure
383         }
384         string displayName;
385         State state;
386         int buildStatus;
387         int runStatus;
388         string buildOutput;
389         string runOutput;
390     }
391     Test[] tests;
392 
393     void run()
394     {
395         Console.attach();
396         this.buffer = Console.createTextBuffer();
397         this.layout = Layout(
398             Rect(0, 0, this.buffer.width, this.buffer.height),
399             8, 8
400         );
401 
402         this.tests = TEST_CASES.map!(t => Test(" " ~ t.folder ~ " " ~ t.params ~ " ")).array;
403 
404         while(Console.isAttached)
405         {
406             Console.processEvents((e)
407             {
408                 e.match!(
409                     (ConsoleKeyEvent key) => handleKey(key),
410                     (_){}
411                 );
412             });
413 
414             if(Console.isAttached)
415                 this.draw();
416         }
417     }
418 
419     private void handleKey(ConsoleKeyEvent key)
420     {
421         if(!key.isDown)
422             return;
423 
424         // If I weren't lazy I'd bring the shift and ctrl keys into play, but I'm lazy.
425         if(key.key == ConsoleKey.escape)
426             Console.detach();
427         else if(key.key == ConsoleKey.up && this.selectedTest != 0)
428             this.selectedTest--;
429         else if(key.key == ConsoleKey.down && this.selectedTest < TEST_CASES.length-1)
430             this.selectedTest++;
431         else if(key.key == ConsoleKey.enter)
432             this.runTest(this.selectedTest);
433         else if(key.key == ConsoleKey.home && this.buildOffset != 0)
434             this.buildOffset--;
435         else if(key.key == ConsoleKey.end)
436             this.buildOffset++;
437         else if(key.key == ConsoleKey.insert && this.runOffset != 0)
438             this.runOffset--;
439         else if(key.key == ConsoleKey.del)
440             this.runOffset++;
441         else if(key.key == ConsoleKey.right)
442             this.buildXOffset++;
443         else if(key.key == ConsoleKey.left && this.buildXOffset != 0)
444             this.buildXOffset--;
445         else if(key.key == ConsoleKey.back)
446         {
447             foreach(i; 0..TEST_CASES.length)
448                 this.runTest(i);
449         }
450     }
451 
452     private void runTest(size_t test)
453     {
454         const TestResult result = .runTest(TEST_CASES[test]);
455         this.tests[test].state = (result.passed) ? Test.State.success : Test.State.failure;
456         this.tests[test].buildOutput = result.buildOut;
457         this.tests[test].runOutput = result.runOut;
458     }
459 
460     private void draw()
461     {
462         auto testBlock = BorderWidgetBuilder()
463             .withBackground(AnsiColour(Ansi4BitColour.black))
464             .withForeground(AnsiColour(Ansi4BitColour.yellow))
465             .withBlockArea(Rect(0, 0, 3, 7))
466             .withBorderStyle(BorderStyle.all)
467             .withTitle("TESTS")
468             .withTitleAlignment(Alignment.center)
469             .build();
470 
471         auto resultBlock = BorderWidgetBuilder()
472             .withBackground(AnsiColour(Ansi4BitColour.black))
473             .withForeground(AnsiColour(Ansi4BitColour.yellow))
474             .withBlockArea(Rect(3, 0, 8, 7))
475             .withBorderStyle(BorderStyle.all)
476             .withTitle("RESULT")
477             .withTitleAlignment(Alignment.center)
478             .build();
479 
480         testBlock.render(this.layout, this.buffer);
481         resultBlock.render(this.layout, this.buffer);
482 
483         const testArea = testBlock.innerArea(this.layout);
484         const testLayout = Layout(testArea, testArea.width, testArea.height);
485         foreach(i, test; this.tests[this.selectedTest..$])
486         {
487             const fg = 
488                 (test.state == Test.State.notRan)
489                     ? AnsiColour.init
490                     : (test.state == Test.State.failure)
491                         ? AnsiColour(Ansi4BitColour.red)
492                         : AnsiColour(Ansi4BitColour.green);
493 
494             TextWidgetBuilder()
495                 .withBlockArea(Rect(2, cast(int)i, testArea.width, cast(int)i+1))
496                 .withText(test.displayName)
497                 .withStyle(i == 0 ? AnsiStyleSet.init.bg(AnsiColour(Ansi4BitColour.blue)).fg(fg) : AnsiStyleSet.init.fg(fg))
498                 .build()
499                 .render(testLayout, this.buffer);
500         }
501 
502         const resultArea   = resultBlock.innerArea(this.layout);
503         const resultLayout = Layout(resultArea, 2, 1);
504         const buildArea    = resultLayout.blockRectToRealRect(Rect(0, 0, 1, 1));
505         const runArea      = resultLayout.blockRectToRealRect(Rect(1, 0, 2, 1));
506         const buildLayout  = Layout(buildArea, 1, 1);
507         const runLayout    = Layout(runArea, 1, 1);
508 
509         auto buildBlock = BorderWidgetBuilder()
510             .withBlockArea(Rect(0, 0, 1, 1))
511             .withBorderStyle(BorderStyle.all)
512             .withTitle("Build")
513             .withTitleAlignment(Alignment.center)
514             .build();
515         buildBlock.render(buildLayout, this.buffer);
516 
517         auto runBlock = BorderWidgetBuilder()
518             .withBlockArea(Rect(0, 0, 1, 1))
519             .withBorderStyle(BorderStyle.all)
520             .withTitle("Run")
521             .withTitleAlignment(Alignment.center)
522             .build();
523         runBlock.render(runLayout, this.buffer);
524 
525         const buildOutArea      = buildBlock.innerArea(buildLayout);
526         const runOutArea        = runBlock.innerArea(runLayout);
527         const buildOutLayout    = Layout(buildOutArea, 1, buildOutArea.height);
528         const runOutLayout      = Layout(runOutArea, 1, runOutArea.height);
529 
530         foreach(i, line; this.tests[this.selectedTest].buildOutput.lineSplitter.drop(this.buildOffset).enumerate)
531         {
532             TextWidgetBuilder()
533                 .withBlockArea(Rect(0, cast(int)i, 1, cast(int)i+1))
534                 .withText(line[min(this.buildXOffset, line.length)..$])
535                 .build()
536                 .render(buildOutLayout, this.buffer);
537         }
538 
539         foreach(i, line; this.tests[this.selectedTest].runOutput.lineSplitter.drop(this.runOffset).enumerate)
540         {
541             TextWidgetBuilder()
542                 .withBlockArea(Rect(0, cast(int)i, 1, cast(int)i+1))
543                 .withText(line[min(this.buildXOffset, line.length)..$])
544                 .build()
545                 .render(runOutLayout, this.buffer);
546         }
547 
548         ShortcutsWidgetBuilder!7()
549             .withBackground(AnsiColour(Ansi4BitColour.brightBlack))
550             .withKeyStyle(AnsiStyleSet.init.bg(AnsiColour(Ansi4BitColour.blue)))
551             .withDescriptionStyle(AnsiStyleSet.init.bg(AnsiColour(Ansi4BitColour.brightBlack)))
552             .withShortcut(0, "ESC", "Close UI")
553             .withShortcut(1, "↑↓", "Select test")
554             .withShortcut(2, "ENTER", "Run test")
555             .withShortcut(3, "BACKSPACE", "Run all tests")
556             .withShortcut(4, "INS DEL", "Move Build Output")
557             .withShortcut(5, "PGUP PGDN", "Move Run Output")
558             .withShortcut(6, "←→", "Offset Output")
559             .build()
560             .render(this.buffer);
561 
562         this.buffer.refresh();
563     }
564 }