packages/requirements/tests/Integration/Console/ExtensionCommandTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Integration\Console;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\DataProvider;
9use PHPUnit\Framework\Attributes\Medium;
10use PHPUnit\Framework\Attributes\UsesClass;
11use PHPUnit\Framework\TestCase;
12use Requirements\Config\Bootstrap;
13use Requirements\Console\Application;
14use Requirements\Console\CommandHandler;
15use Requirements\Console\CommandLine;
16use Requirements\Console\Executor;
17use Requirements\Console\SpecificationReport;
18use Requirements\Source\ResourceLoader;
19use Requirements\Test\ProcessRunner;
20use Tests\Fake\CommandLine as Cli;
21use Tests\Fake\ProjectDirectory;
22
23#[CoversClass(Application::class)]
24#[UsesClass(CommandLine::class)]
25#[UsesClass(CommandHandler::class)]
26#[UsesClass(Executor::class)]
27#[UsesClass(SpecificationReport::class)]
28#[UsesClass(Bootstrap::class)]
29#[UsesClass(ResourceLoader::class)]
30#[UsesClass(ProcessRunner::class)]
31#[Medium]
32final class ExtensionCommandTest extends TestCase
33{
34 #[DataProvider('providerCommands')]
35 public function testRunExecutesCustomSourcesAndRunnersRegisteredThroughABootstrapFile(string $command, string $expected): void
36 {
37 $project = new ProjectDirectory();
38 $project->write('requirements.yaml', [
39 'version' => 1,
40 'bootstrap' => 'bootstrap.php',
41 'definitions' => ['catalog.yaml'],
42 'extensions' => ['sources' => ['catalog' => 'SampleExtension\\CatalogSource'], 'runners' => ['scenario' => 'SampleExtension\\ScenarioRunner']],
43 'runners' => ['acceptance' => ['extension' => 'scenario', 'command' => [PHP_BINARY, 'scenario.php'], 'timeout' => 10]],
44 'coverage' => ['minimum' => 100],
45 ]);
46 $project->write('catalog.yaml', [
47 'version' => 1,
48 'source' => ['id' => 'converter-catalog', 'uri' => 'catalog.json', 'format' => 'catalog', 'selector' => '*'],
49 'items' => [[
50 'id' => 'CONVERTER-001',
51 'statement' => 'When text contains lowercase ASCII letters, the converter shall uppercase those letters.',
52 'evidence' => [['selector' => 'ascii-uppercase', 'quote' => 'The converter uppercases lowercase ASCII letters.']],
53 'tests' => [['runner' => 'acceptance', 'target' => 'ascii-uppercase']],
54 ]],
55 ]);
56 $project->put('catalog.json', "{\n \"ascii-uppercase\": \"The converter uppercases lowercase ASCII letters.\"\n}\n");
57 $project->put('bootstrap.php', <<<'PHP'
58<?php
59
60declare(strict_types=1);
61
62require_once __DIR__ . '/CatalogSource.php';
63require_once __DIR__ . '/ScenarioRunner.php';
64PHP);
65 $project->put('CatalogSource.php', <<<'PHP'
66<?php
67
68declare(strict_types=1);
69
70namespace SampleExtension;
71
72use Requirements\Model\Source;
73use Requirements\Source\ResourceLoader;
74use Requirements\Source\SourceExtension;
75use Requirements\Source\Unit;
76use RuntimeException;
77use stdClass;
78
79final class CatalogSource implements SourceExtension
80{
81 public function __construct(private readonly ResourceLoader $loader = new ResourceLoader())
82 {
83 }
84
85 /**
86 * @return list<Unit>
87 */
88 public function select(Source $source, string $selector, string $directory, bool $live): array
89 {
90 $catalog = json_decode($this->loader->read($source, $directory, $live), false, 512, JSON_THROW_ON_ERROR);
91 if (!$catalog instanceof stdClass) {
92 throw new RuntimeException('The catalog must map stable entry IDs to source text.');
93 }
94 $units = [];
95 foreach (get_object_vars($catalog) as $id => $text) {
96 if (!is_string($id) || $id === '' || !is_string($text) || trim($text) === '') {
97 throw new RuntimeException('Every catalog entry needs a nonempty ID and text.');
98 }
99 if ($selector === '*' || $selector === $id) {
100 $units[] = new Unit('entry:' . $id, $text);
101 }
102 }
103 return $units;
104 }
105}
106PHP);
107 $project->put('ScenarioRunner.php', <<<'PHP'
108<?php
109
110declare(strict_types=1);
111
112namespace SampleExtension;
113
114use Requirements\Test\ProcessRunner;
115use Requirements\Test\RunnerConfig;
116use Requirements\Test\RunnerExtension;
117use Requirements\Test\TestResult;
118
119final class ScenarioRunner implements RunnerExtension
120{
121 public function run(RunnerConfig $config, string $target): TestResult
122 {
123 return (new ProcessRunner())->run($config, static fn (string $directory): array => ['--case', $target, '--junit', $directory . '/results.xml']);
124 }
125}
126PHP);
127 $project->put('scenario.php', <<<'PHP'
128<?php
129
130declare(strict_types=1);
131
132$options = getopt('', ['case:', 'junit:']);
133if ($options === false || !isset($options['case'], $options['junit']) || !is_string($options['case']) || !is_string($options['junit'])) {
134 fwrite(STDERR, "Usage: php scenario.php --case ascii-uppercase --junit FILE\n");
135 exit(2);
136}
137if ($options['case'] !== 'ascii-uppercase') {
138 fwrite(STDERR, "Unknown case.\n");
139 exit(2);
140}
141$passed = strtoupper('example') === 'EXAMPLE';
142$failure = $passed ? '' : '<failure message="Expected uppercase ASCII letters"/>';
143$xml = '<testsuite tests="1"><testcase name="ascii-uppercase">' . $failure . '</testcase></testsuite>';
144if (file_put_contents($options['junit'], $xml) === false) {
145 exit(2);
146}
147exit($passed ? 0 : 1);
148PHP);
149 $process = Cli::run([$command, '--config', $project->path('requirements.yaml'), '--json'], $project->directory);
150 self::assertSame(0, $process->getExitCode(), $process->getOutput() . $process->getErrorOutput());
151 self::assertStringContainsString('"passed": true', $process->getOutput());
152 self::assertStringContainsString($expected, $process->getOutput());
153 }
154
155 /**
156 * @return array<string, array{string, string}>
157 */
158 public static function providerCommands(): array
159 {
160 return [
161 'lint' => ['lint', '"message": "1 items validated."'],
162 'check' => ['check', '"CONVERTER-001"'],
163 'coverage' => ['coverage', '"percentage": 100'],
164 'spec' => ['spec', '"tests": 1'],
165 ];
166 }
167}
168