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