|
| 1 | +<?php |
| 2 | + |
| 3 | +declare(strict_types=1); |
| 4 | + |
| 5 | +namespace Tests\Unit\Execution; |
| 6 | + |
| 7 | +use PHPUnit\Framework\TestCase; |
| 8 | +use Tests\Doubles\OutputHandlerSpy; |
| 9 | +use Wtyd\GitHooks\Configuration\JobConfiguration; |
| 10 | +use Wtyd\GitHooks\Configuration\OptionsConfiguration; |
| 11 | +use Wtyd\GitHooks\Execution\FlowExecutor; |
| 12 | +use Wtyd\GitHooks\Execution\FlowPlan; |
| 13 | +use Wtyd\GitHooks\Jobs\CustomJob; |
| 14 | +use Wtyd\GitHooks\Jobs\PhpstanJob; |
| 15 | + |
| 16 | +/** |
| 17 | + * BUG-18: covers the decision table of the humanisation step in |
| 18 | + * `FlowExecutor::buildResult`. |
| 19 | + * |
| 20 | + * Factors: |
| 21 | + * - structuredFormat: true / false |
| 22 | + * - Parser registered for the job type: yes (phpstan, …) / no (local-script) |
| 23 | + * |
| 24 | + * Invariants under test for every row: |
| 25 | + * - `JobResult.output` is ALWAYS the raw process output (never humanised). |
| 26 | + * The file-based formatters (JsonResultFormatter / JunitResultFormatter / |
| 27 | + * SarifResultFormatter / CodeClimateResultFormatter) all consume that |
| 28 | + * field and must keep getting the JSON they parse. |
| 29 | + * - The OutputHandler sees the humanised version ONLY when both |
| 30 | + * structuredFormat = true AND a parser is registered for the job type; |
| 31 | + * otherwise it sees the raw output (unchanged from pre-BUG-18 behaviour). |
| 32 | + */ |
| 33 | +class FlowExecutorHumanizeOutputTest extends TestCase |
| 34 | +{ |
| 35 | + /** |
| 36 | + * Row A — structuredFormat=true, parser registered. |
| 37 | + * onJobError receives the human listing; JobResult.output stays JSON. |
| 38 | + * |
| 39 | + * @test |
| 40 | + */ |
| 41 | + public function row_a_structured_with_parser_humanises_for_handler_but_keeps_raw_in_jobresult(): void |
| 42 | + { |
| 43 | + $rawJson = '{"totals":{"errors":1},"files":{"src/Foo.php":' |
| 44 | + . '{"errors":1,"messages":[{"message":"Class Foo not found.","line":42,' |
| 45 | + . '"identifier":"class.notFound"}]}}}'; |
| 46 | + |
| 47 | + $job = $this->phpstanJobEmitting($rawJson); |
| 48 | + |
| 49 | + $spy = new OutputHandlerSpy(); |
| 50 | + $executor = new FlowExecutor($spy); |
| 51 | + $executor->setStructuredFormat(true); |
| 52 | + |
| 53 | + $result = $executor->execute(new FlowPlan('test', [$job], new OptionsConfiguration(false, 1))); |
| 54 | + |
| 55 | + $this->assertCount(1, $spy->errorJobs); |
| 56 | + $handlerOutput = $spy->errorJobs[0]['output']; |
| 57 | + |
| 58 | + // Humanised content reached the handler. |
| 59 | + $this->assertStringContainsString('src/Foo.php', $handlerOutput); |
| 60 | + $this->assertStringContainsString('line 42', $handlerOutput); |
| 61 | + $this->assertStringContainsString('[class.notFound]', $handlerOutput); |
| 62 | + $this->assertStringNotContainsString('"totals":', $handlerOutput); |
| 63 | + |
| 64 | + // JobResult preserves the raw JSON for file-based formatters. |
| 65 | + $jobResult = $result->getJobResults()[0]; |
| 66 | + $this->assertStringContainsString('"totals":', $jobResult->getOutput()); |
| 67 | + $this->assertStringContainsString('"files":', $jobResult->getOutput()); |
| 68 | + } |
| 69 | + |
| 70 | + /** |
| 71 | + * Row B — structuredFormat=true, NO parser for jobType (local-script). |
| 72 | + * Falls back to the raw output for both the handler and the JobResult. |
| 73 | + * |
| 74 | + * @test |
| 75 | + */ |
| 76 | + public function row_b_structured_without_parser_falls_back_to_raw_on_both_paths(): void |
| 77 | + { |
| 78 | + $raw = "custom failure: missing config\nexpected file not found"; |
| 79 | + $job = $this->localScriptJobEmitting($raw); |
| 80 | + |
| 81 | + $spy = new OutputHandlerSpy(); |
| 82 | + $executor = new FlowExecutor($spy); |
| 83 | + $executor->setStructuredFormat(true); |
| 84 | + |
| 85 | + $result = $executor->execute(new FlowPlan('test', [$job], new OptionsConfiguration(false, 1))); |
| 86 | + |
| 87 | + $this->assertCount(1, $spy->errorJobs); |
| 88 | + $this->assertStringContainsString('custom failure', $spy->errorJobs[0]['output']); |
| 89 | + $this->assertStringContainsString('custom failure', $result->getJobResults()[0]->getOutput()); |
| 90 | + } |
| 91 | + |
| 92 | + /** |
| 93 | + * Row C — structuredFormat=false, parser registered. |
| 94 | + * No humanisation; the handler sees the raw output exactly as the JobResult does. |
| 95 | + * |
| 96 | + * @test |
| 97 | + */ |
| 98 | + public function row_c_unstructured_with_parser_does_not_humanise(): void |
| 99 | + { |
| 100 | + // With structuredFormat=false the tool would emit its native human |
| 101 | + // output, not JSON. Simulate that — and confirm the executor does |
| 102 | + // not invoke the formatter (which would otherwise fall back to raw |
| 103 | + // anyway, but we want to guarantee the structuredFormat guard short- |
| 104 | + // circuits BEFORE invoking the formatter). |
| 105 | + $raw = " ------ ------- \n Line src/Foo.php\n 42 Class Foo not found.\n"; |
| 106 | + $job = $this->phpstanJobEmitting($raw); |
| 107 | + |
| 108 | + $spy = new OutputHandlerSpy(); |
| 109 | + $executor = new FlowExecutor($spy); |
| 110 | + // structuredFormat stays at its default (false). |
| 111 | + |
| 112 | + $result = $executor->execute(new FlowPlan('test', [$job], new OptionsConfiguration(false, 1))); |
| 113 | + |
| 114 | + $this->assertCount(1, $spy->errorJobs); |
| 115 | + $this->assertSame( |
| 116 | + $result->getJobResults()[0]->getOutput(), |
| 117 | + $spy->errorJobs[0]['output'], |
| 118 | + 'When structuredFormat is off the handler must receive the raw output unchanged' |
| 119 | + ); |
| 120 | + $this->assertStringContainsString('Class Foo not found.', $spy->errorJobs[0]['output']); |
| 121 | + } |
| 122 | + |
| 123 | + /** |
| 124 | + * Row D — structuredFormat=false, no parser for jobType. |
| 125 | + * Same as C: raw on both paths. |
| 126 | + * |
| 127 | + * @test |
| 128 | + */ |
| 129 | + public function row_d_unstructured_without_parser_does_not_humanise(): void |
| 130 | + { |
| 131 | + $raw = "plain failure message\n"; |
| 132 | + $job = $this->localScriptJobEmitting($raw); |
| 133 | + |
| 134 | + $spy = new OutputHandlerSpy(); |
| 135 | + $executor = new FlowExecutor($spy); |
| 136 | + |
| 137 | + $result = $executor->execute(new FlowPlan('test', [$job], new OptionsConfiguration(false, 1))); |
| 138 | + |
| 139 | + $this->assertCount(1, $spy->errorJobs); |
| 140 | + $this->assertSame( |
| 141 | + $result->getJobResults()[0]->getOutput(), |
| 142 | + $spy->errorJobs[0]['output'] |
| 143 | + ); |
| 144 | + $this->assertStringContainsString('plain failure message', $spy->errorJobs[0]['output']); |
| 145 | + } |
| 146 | + |
| 147 | + /** |
| 148 | + * Returns a PhpstanJob subclass whose `buildCommand()` produces $raw on |
| 149 | + * stdout and exits 1, irrespective of the real `applyStructuredOutputFormat()` |
| 150 | + * the executor invokes when structuredFormat=true (those args would feed a |
| 151 | + * real phpstan binary; here we short-circuit with sh). |
| 152 | + */ |
| 153 | + private function phpstanJobEmitting(string $raw): PhpstanJob |
| 154 | + { |
| 155 | + $job = new class (new JobConfiguration('phpstan_src', 'phpstan', ['paths' => ['src']])) extends PhpstanJob { |
| 156 | + public string $rawPayload = ''; |
| 157 | + public function buildCommand(): string |
| 158 | + { |
| 159 | + $b64 = base64_encode($this->rawPayload); |
| 160 | + return "sh -c 'echo {$b64} | base64 -d; exit 1'"; |
| 161 | + } |
| 162 | + }; |
| 163 | + $job->rawPayload = $raw; |
| 164 | + return $job; |
| 165 | + } |
| 166 | + |
| 167 | + /** |
| 168 | + * Returns a CustomJob (type=local-script — not in ToolOutputParserRegistry) |
| 169 | + * that emits $raw and exits 1. |
| 170 | + */ |
| 171 | + private function localScriptJobEmitting(string $raw): CustomJob |
| 172 | + { |
| 173 | + $b64 = base64_encode($raw); |
| 174 | + return new CustomJob(new JobConfiguration( |
| 175 | + 'my_local_script', |
| 176 | + 'local-script', |
| 177 | + ['script' => "sh -c 'echo {$b64} | base64 -d; exit 1'"] |
| 178 | + )); |
| 179 | + } |
| 180 | +} |
0 commit comments