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 ("./08-arg-binder-validation") 136 .withParams ("20 69") 137 .expectStatusToBe (0) 138 .finish (), 139 testCase().inFolder ("./08-arg-binder-validation") 140 .withParams ("69 69") 141 .expectStatusToBe (-1) 142 .expectOutputToMatch ("Expected number to be even") 143 .finish (), 144 testCase().inFolder ("./08-arg-binder-validation") 145 .withParams ("20 20") 146 .expectStatusToBe (-1) 147 .expectOutputToMatch ("Expected number to be odd") 148 .finish (), 149 150 testCase().inFolder ("./09-raw-unparsed-arg-list") 151 .withParams ("echo -- Some args") 152 .expectStatusToBe (0) 153 .expectOutputToMatch (`Running command 'echo' with arguments \["Some", "args"\]`) 154 .finish (), 155 testCase().inFolder ("./09-raw-unparsed-arg-list") 156 .withParams ("noarg") 157 .expectStatusToBe (0) 158 .expectOutputToMatch (`Running command 'noarg' with arguments \[\]`) 159 .finish (), 160 161 testCase().inFolder ("./10-argument-options") 162 .withParams ("sensitive --abc 2") 163 .expectStatusToBe (0) 164 .finish (), 165 testCase().inFolder ("./10-argument-options") 166 .withParams ("sensitive --abC 2") 167 .expectStatusToBe (-1) 168 .finish (), 169 testCase().inFolder ("./10-argument-options") 170 .withParams ("insensitive --abc 2") 171 .expectStatusToBe (0) 172 .finish (), 173 testCase().inFolder ("./10-argument-options") 174 .withParams ("insensitive --ABC 2") 175 .expectStatusToBe (0) 176 .finish (), 177 testCase().inFolder ("./10-argument-options") 178 .withParams ("redefine --abc 2") 179 .expectStatusToBe (2) 180 .finish (), 181 testCase().inFolder ("./10-argument-options") 182 .withParams ("redefine --abc 2 --abc 1") 183 .expectStatusToBe (1) 184 .finish (), 185 testCase().inFolder ("./10-argument-options") 186 .withParams ("no-redefine --abc 2") 187 .expectStatusToBe (0) 188 .finish (), 189 testCase().inFolder ("./10-argument-options") 190 .withParams ("no-redefine --abc 2 --abc 1") 191 .expectStatusToBe (-1) 192 .finish (), 193 ]; 194 195 /++ MAIN ++/ 196 int main(string[] args) 197 { 198 return (new CommandLineInterface!test()).parseAndExecute(args); 199 } 200 201 /++ COMMANDS ++/ 202 @CommandDefault("Runs all test cases") 203 struct DefaultCommand 204 { 205 int onExecute() 206 { 207 writefln("Running %s tests.", "ALL".ansi.fg(Ansi4BitColour.green)); 208 const anyfails = runTestSet(TEST_CASES); 209 210 return anyfails ? -1 : 0; 211 } 212 } 213 214 @Command("ui", "Runs the test UI") 215 struct UICommand 216 { 217 void onExecute() 218 { 219 auto ui = new UI(); 220 ui.run(); 221 } 222 } 223 224 @Command("cleanup", "Runs the cleanup command for all test cases") 225 struct CleanupCommand 226 { 227 void onExecute() 228 { 229 foreach(test; TEST_CASES) 230 runCleanup(test); 231 } 232 } 233 234 /++ FUNCS ++/ 235 bool runTestSet(TestCase[] testSet) 236 { 237 auto results = new TestResult[testSet.length]; 238 foreach(i, testCase; testSet) 239 results[i] = testCase.runTest(); 240 241 if(results.any!(r => !r.passed)) 242 writefln("\n\nThe following tests %s:", "FAILED".ansi.fg(Ansi4BitColour.red)); 243 244 size_t failedCount; 245 foreach(failed; results.filter!(r => !r.passed)) 246 { 247 failedCount++; 248 writefln("\t%s", failed.testCase.to!string.ansi.fg(Ansi4BitColour.cyan)); 249 250 foreach(reason; failed.failedReasons) 251 writefln("\t\t- %s".ansi.fg(Ansi4BitColour.red).to!string, reason); 252 } 253 254 size_t passedCount = results.length - failedCount; 255 writefln( 256 "\n%s %s, %s %s, %s total tests", 257 passedCount, "PASSED".ansi.fg(Ansi4BitColour.green), 258 failedCount, "FAILED".ansi.fg(Ansi4BitColour.red), 259 results.length 260 ); 261 262 return (failedCount != 0); 263 } 264 265 TestResult runTest(TestCase testCase) 266 { 267 const results = getBuildAndTestResults(testCase); 268 auto passed = true; 269 string[] reasons; 270 271 void failIf(bool condition, string reason) 272 { 273 if(!condition) 274 return; 275 276 passed = false; 277 reasons ~= reason; 278 } 279 280 failIf(results[0].status != 0, "Build failed."); 281 282 // When handling the status code, some terminals allow negative status codes, some don't, so we'll special case expecting 283 // a -1 as expecting -1 or 255. 284 if(testCase.expectedStatus.get(0) != -1) 285 failIf(results[1].status != testCase.expectedStatus.get(0), "Status code is wrong."); 286 else 287 failIf(results[1].status != -1 && results[1].status != 255, "Status code is wrong. (-1 special case)"); 288 289 if(!testCase.outputRegex.isNull) 290 failIf(!results[1].output.matchFirst(testCase.outputRegex.get), "Output doesn't contain a match for the given regex."); 291 292 if(testCase.allowedToFail) 293 { 294 if(!passed) 295 writeln("Test FAILED (ALLOWED).".ansi.fg(Ansi4BitColour.yellow)); 296 passed = true; 297 } 298 299 if(!passed) 300 writeln("Test FAILED".ansi.fg(Ansi4BitColour.red)); 301 else 302 writefln("%s", "Test PASSED".ansi.fg(Ansi4BitColour.green)); 303 304 return TestResult(passed, reasons, testCase, results[0].status, results[1].status, results[0].output, results[1].output); 305 } 306 307 // [0] = build result, [1] = test result 308 auto getBuildAndTestResults(TestCase testCase) 309 { 310 const CATEGORY_COLOUR = Ansi4BitColour.magenta; 311 const VALUE_COLOUR = Ansi4BitColour.brightBlue; 312 const RESULT_COLOUR = Ansi4BitColour.yellow; 313 314 writefln(""); 315 writefln("%s", "[Test Case]".ansi.fg(CATEGORY_COLOUR)); 316 writefln("%s: %s", "Folder".ansi.fg(CATEGORY_COLOUR), testCase.folder.ansi.fg(VALUE_COLOUR)); 317 writefln("%s: %s", "Params".ansi.fg(CATEGORY_COLOUR), testCase.params.ansi.fg(VALUE_COLOUR)); 318 writefln("%s: %s", "Status".ansi.fg(CATEGORY_COLOUR), testCase.expectedStatus.get(0).to!string.ansi.fg(RESULT_COLOUR)); 319 writefln("%s: %s", "Regex ".ansi.fg(CATEGORY_COLOUR), testCase.outputRegex.get(regex("N/A")).to!string.ansi.fg(RESULT_COLOUR)); 320 321 auto cwd = getcwd(); 322 chdir(testCase.folder); 323 scope(exit) chdir(cwd); 324 325 foreach(file; testCase.cleanupFiles.filter!(f => f.exists)) 326 { 327 writefln("Cleanup: %s".ansi.fg(Ansi4BitColour.brightBlack).to!string, file); 328 remove(file); 329 } 330 331 const buildString = "dub build --compiler=ldc2"; 332 const commandString = "\"./test\" " ~ testCase.params; 333 const buildResult = executeShell(buildString); 334 const result = executeShell(commandString); 335 336 writefln("\n%s(status: %s):\n%s", "Build output".ansi.fg(CATEGORY_COLOUR), buildResult.status.to!string.ansi.fg(RESULT_COLOUR), buildResult.output); 337 writefln("%s(status: %s):\n%s", "Test output".ansi.fg(CATEGORY_COLOUR), result.status.to!string.ansi.fg(RESULT_COLOUR), result.output); 338 339 return [buildResult, result]; 340 } 341 342 void runCleanup(TestCase testCase) 343 { 344 auto cwd = getcwd(); 345 chdir(testCase.folder); 346 scope(exit) chdir(cwd); 347 executeShell("dub clean"); 348 } 349 350 final class UI 351 { 352 TextBuffer buffer; 353 Layout layout; 354 size_t selectedTest; 355 size_t buildOffset; 356 size_t buildXOffset; 357 size_t runOffset; 358 359 static struct Test 360 { 361 enum State 362 { 363 notRan, 364 success, 365 failure 366 } 367 string displayName; 368 State state; 369 int buildStatus; 370 int runStatus; 371 string buildOutput; 372 string runOutput; 373 } 374 Test[] tests; 375 376 void run() 377 { 378 Console.attach(); 379 this.buffer = Console.createTextBuffer(); 380 this.layout = Layout( 381 Rect(0, 0, this.buffer.width, this.buffer.height), 382 8, 8 383 ); 384 385 this.tests = TEST_CASES.map!(t => Test(" " ~ t.folder ~ " " ~ t.params ~ " ")).array; 386 387 while(Console.isAttached) 388 { 389 Console.processEvents((e) 390 { 391 e.match!( 392 (ConsoleKeyEvent key) => handleKey(key), 393 (_){} 394 ); 395 }); 396 397 if(Console.isAttached) 398 this.draw(); 399 } 400 } 401 402 private void handleKey(ConsoleKeyEvent key) 403 { 404 if(!key.isDown) 405 return; 406 407 // If I weren't lazy I'd bring the shift and ctrl keys into play, but I'm lazy. 408 if(key.key == ConsoleKey.escape) 409 Console.detach(); 410 else if(key.key == ConsoleKey.up && this.selectedTest != 0) 411 this.selectedTest--; 412 else if(key.key == ConsoleKey.down && this.selectedTest < TEST_CASES.length-1) 413 this.selectedTest++; 414 else if(key.key == ConsoleKey.enter) 415 this.runTest(this.selectedTest); 416 else if(key.key == ConsoleKey.home && this.buildOffset != 0) 417 this.buildOffset--; 418 else if(key.key == ConsoleKey.end) 419 this.buildOffset++; 420 else if(key.key == ConsoleKey.insert && this.runOffset != 0) 421 this.runOffset--; 422 else if(key.key == ConsoleKey.del) 423 this.runOffset++; 424 else if(key.key == ConsoleKey.right) 425 this.buildXOffset++; 426 else if(key.key == ConsoleKey.left && this.buildXOffset != 0) 427 this.buildXOffset--; 428 else if(key.key == ConsoleKey.back) 429 { 430 foreach(i; 0..TEST_CASES.length) 431 this.runTest(i); 432 } 433 } 434 435 private void runTest(size_t test) 436 { 437 const TestResult result = .runTest(TEST_CASES[test]); 438 this.tests[test].state = (result.passed) ? Test.State.success : Test.State.failure; 439 this.tests[test].buildOutput = result.buildOut; 440 this.tests[test].runOutput = result.runOut; 441 } 442 443 private void draw() 444 { 445 auto testBlock = BorderWidgetBuilder() 446 .withBackground(AnsiColour(Ansi4BitColour.black)) 447 .withForeground(AnsiColour(Ansi4BitColour.yellow)) 448 .withBlockArea(Rect(0, 0, 3, 7)) 449 .withBorderStyle(BorderStyle.all) 450 .withTitle("TESTS") 451 .withTitleAlignment(Alignment.center) 452 .build(); 453 454 auto resultBlock = BorderWidgetBuilder() 455 .withBackground(AnsiColour(Ansi4BitColour.black)) 456 .withForeground(AnsiColour(Ansi4BitColour.yellow)) 457 .withBlockArea(Rect(3, 0, 8, 7)) 458 .withBorderStyle(BorderStyle.all) 459 .withTitle("RESULT") 460 .withTitleAlignment(Alignment.center) 461 .build(); 462 463 testBlock.render(this.layout, this.buffer); 464 resultBlock.render(this.layout, this.buffer); 465 466 const testArea = testBlock.innerArea(this.layout); 467 const testLayout = Layout(testArea, testArea.width, testArea.height); 468 foreach(i, test; this.tests[this.selectedTest..$]) 469 { 470 const fg = 471 (test.state == Test.State.notRan) 472 ? AnsiColour.init 473 : (test.state == Test.State.failure) 474 ? AnsiColour(Ansi4BitColour.red) 475 : AnsiColour(Ansi4BitColour.green); 476 477 TextWidgetBuilder() 478 .withBlockArea(Rect(2, cast(int)i, testArea.width, cast(int)i+1)) 479 .withText(test.displayName) 480 .withStyle(i == 0 ? AnsiStyleSet.init.bg(AnsiColour(Ansi4BitColour.blue)).fg(fg) : AnsiStyleSet.init.fg(fg)) 481 .build() 482 .render(testLayout, this.buffer); 483 } 484 485 const resultArea = resultBlock.innerArea(this.layout); 486 const resultLayout = Layout(resultArea, 2, 1); 487 const buildArea = resultLayout.blockRectToRealRect(Rect(0, 0, 1, 1)); 488 const runArea = resultLayout.blockRectToRealRect(Rect(1, 0, 2, 1)); 489 const buildLayout = Layout(buildArea, 1, 1); 490 const runLayout = Layout(runArea, 1, 1); 491 492 auto buildBlock = BorderWidgetBuilder() 493 .withBlockArea(Rect(0, 0, 1, 1)) 494 .withBorderStyle(BorderStyle.all) 495 .withTitle("Build") 496 .withTitleAlignment(Alignment.center) 497 .build(); 498 buildBlock.render(buildLayout, this.buffer); 499 500 auto runBlock = BorderWidgetBuilder() 501 .withBlockArea(Rect(0, 0, 1, 1)) 502 .withBorderStyle(BorderStyle.all) 503 .withTitle("Run") 504 .withTitleAlignment(Alignment.center) 505 .build(); 506 runBlock.render(runLayout, this.buffer); 507 508 const buildOutArea = buildBlock.innerArea(buildLayout); 509 const runOutArea = runBlock.innerArea(runLayout); 510 const buildOutLayout = Layout(buildOutArea, 1, buildOutArea.height); 511 const runOutLayout = Layout(runOutArea, 1, runOutArea.height); 512 513 foreach(i, line; this.tests[this.selectedTest].buildOutput.lineSplitter.drop(this.buildOffset).enumerate) 514 { 515 TextWidgetBuilder() 516 .withBlockArea(Rect(0, cast(int)i, 1, cast(int)i+1)) 517 .withText(line[min(this.buildXOffset, line.length)..$]) 518 .build() 519 .render(buildOutLayout, this.buffer); 520 } 521 522 foreach(i, line; this.tests[this.selectedTest].runOutput.lineSplitter.drop(this.runOffset).enumerate) 523 { 524 TextWidgetBuilder() 525 .withBlockArea(Rect(0, cast(int)i, 1, cast(int)i+1)) 526 .withText(line[min(this.buildXOffset, line.length)..$]) 527 .build() 528 .render(runOutLayout, this.buffer); 529 } 530 531 ShortcutsWidgetBuilder!7() 532 .withBackground(AnsiColour(Ansi4BitColour.brightBlack)) 533 .withKeyStyle(AnsiStyleSet.init.bg(AnsiColour(Ansi4BitColour.blue))) 534 .withDescriptionStyle(AnsiStyleSet.init.bg(AnsiColour(Ansi4BitColour.brightBlack))) 535 .withShortcut(0, "ESC", "Close UI") 536 .withShortcut(1, "↑↓", "Select test") 537 .withShortcut(2, "ENTER", "Run test") 538 .withShortcut(3, "BACKSPACE", "Run all tests") 539 .withShortcut(4, "INS DEL", "Move Build Output") 540 .withShortcut(5, "PGUP PGDN", "Move Run Output") 541 .withShortcut(6, "←→", "Offset Output") 542 .build() 543 .render(this.buffer); 544 545 this.buffer.refresh(); 546 } 547 }