packages/deriver/tests/Integration/CommandTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Integration;
6
7use Deriver\Command\Application;
8use org\bovigo\vfs\vfsStream;
9use Override;
10use phpmock\Mock;
11use phpmock\MockBuilder;
12use PHPUnit\Framework\Attributes\CoversNothing;
13use PHPUnit\Framework\Attributes\PreserveGlobalState;
14use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
15use PHPUnit\Framework\TestCase;
16use Symfony\Component\Console\Tester\ApplicationTester;
17
18#[CoversNothing]
19#[RunTestsInSeparateProcesses]
20#[PreserveGlobalState(false)]
21final class CommandTest extends TestCase
22{
23    /**
24     * @throws \phpmock\MockEnabledException
25     */
26    #[Override]
27    protected function setUp(): void
28    {
29        parent::setUp();
30        vfsStream::setup('project', null, [
31            'src' => ['Main.php' => '<?php throw new RuntimeException("Application files must not run"); function answer() { return 42; } function pending($id) { return $id; } function environment() { return getenv("APP_ENV"); }'],
32            'ignored' => ['Invalid.php' => '<?php invalid syntax !'],
33            'deliver.yaml' => "paths: [src]\ninclude: ['*.php']\nphpVersion: '8.3'\nformat: json\nenvironment: {APP_ENV: test}\n",
34        ]);
35        (new MockBuilder())->setNamespace('Deriver')->setName('is_dir')->setFunction(static fn (string $path): bool => is_dir('vfs:/' . $path))->build()->enable();
36        (new MockBuilder())->setNamespace('Deriver')->setName('is_readable')->setFunction(static fn (string $path): bool => is_readable('vfs:/' . $path))->build()->enable();
37        (new MockBuilder())->setNamespace('Deriver')->setName('realpath')->setFunction(static fn (string $path): string => 'vfs:/' . $path)->build()->enable();
38        (new MockBuilder())->setNamespace('Deriver\Deriver\Sources')->setName('str_contains')->setFunction(static fn (string $haystack, string $needle): bool => str_starts_with($haystack, 'vfs://') && $needle === '://' ? false : str_contains($haystack, $needle))->build()->enable();
39        (new MockBuilder())->setNamespace('Deriver\Configuration')->setName('is_file')->setFunction(static fn (string $path): bool => is_file('vfs:/' . $path))->build()->enable();
40        (new MockBuilder())->setNamespace('Deriver\Configuration')->setName('is_readable')->setFunction(static fn (string $path): bool => is_readable('vfs:/' . $path))->build()->enable();
41        (new MockBuilder())->setNamespace('Deriver\Configuration')->setName('file_get_contents')->setFunction(static fn (string $path): string|false => file_get_contents('vfs:/' . $path))->build()->enable();
42    }
43
44    public function testCommandSelectsFilesAndDoesNotExecuteApplicationCode(): void
45    {
46        $application = new Application();
47        $application->setAutoExit(false);
48        $tester = new ApplicationTester($application);
49        self::assertSame(0, $tester->run(['query' => 'answer#return', '--path' => ['/project'], '--exclude' => ['ignored']], ['capture_stderr_separately' => true]));
50        self::assertStringContainsString("[0] analyzed int\n42\n", $tester->getDisplay());
51        self::assertSame('', $tester->getErrorOutput());
52    }
53
54    public function testCommandLoadsConfigurationAndEnvironmentInputs(): void
55    {
56        $application = new Application();
57        $application->setAutoExit(false);
58        $tester = new ApplicationTester($application);
59        self::assertSame(0, $tester->run(['query' => 'environment#return', '--config' => '/project/deliver.yaml'], ['capture_stderr_separately' => true]));
60        self::assertStringContainsString('"result": "test"', $tester->getDisplay());
61        self::assertSame('', $tester->getErrorOutput());
62    }
63
64    public function testCommandOptionsOverrideConfiguredListsAndFormat(): void
65    {
66        file_put_contents('vfs://project/deliver.yaml', "paths: [ignored]\ninclude: ['Missing.php']\nexclude: ['Main.php']\nformat: json\n");
67        $application = new Application();
68        $application->setAutoExit(false);
69        $tester = new ApplicationTester($application);
70        self::assertSame(0, $tester->run(['query' => 'answer#return', '--config' => '/project/deliver.yaml', '--path' => ['/project/src'], '--include' => ['Main.php'], '--exclude' => ['Other.php'], '--format' => 'text']));
71        self::assertStringContainsString("[0] analyzed int\n42\n", $tester->getDisplay());
72    }
73
74    public function testCommandTreatsUnresolvedInputsAsCompletedPartialCandidates(): void
75    {
76        $application = new Application();
77        $application->setAutoExit(false);
78        $tester = new ApplicationTester($application);
79        self::assertSame(0, $tester->run(['query' => 'pending#return', '--config' => '/project/deliver.yaml']));
80        self::assertStringContainsString('"type": "partials"', $tester->getDisplay());
81        self::assertStringContainsString('"interrupted": false', $tester->getDisplay());
82    }
83
84    public function testCommandSeparatesParserFailuresFromReportOutput(): void
85    {
86        $application = new Application();
87        $application->setAutoExit(false);
88        $tester = new ApplicationTester($application);
89        self::assertSame(1, $tester->run(['query' => 'answer#return', '--path' => ['/project/ignored'], '--format' => 'json'], ['capture_stderr_separately' => true]));
90        self::assertSame('', $tester->getDisplay());
91        self::assertStringContainsString('Error:', $tester->getErrorOutput());
92    }
93
94    public function testCommandRejectsEmptyFileSelections(): void
95    {
96        $application = new Application();
97        $application->setAutoExit(false);
98        $tester = new ApplicationTester($application);
99        self::assertSame(2, $tester->run(['query' => 'answer#return', '--path' => ['/project/src'], '--include' => ['Missing.php']], ['capture_stderr_separately' => true]));
100        self::assertSame('', $tester->getDisplay());
101        self::assertStringContainsString('No source files matched', $tester->getErrorOutput());
102    }
103
104    public function testCommandKeepsConsoleFormattingTagsLiteral(): void
105    {
106        file_put_contents('vfs://project/src/Main.php', '<?php function answer() { return "<error>literal</error>"; }');
107        $application = new Application();
108        $application->setAutoExit(false);
109        $tester = new ApplicationTester($application);
110        self::assertSame(0, $tester->run(['query' => 'answer#return', '--path' => ['/project/src']], ['decorated' => true]));
111        self::assertStringContainsString("'<error>literal</error>'", $tester->getDisplay());
112    }
113
114    public function testCommandAppliesTheTargetPhpVersionOverride(): void
115    {
116        file_put_contents('vfs://project/src/Main.php', '<?php class Item { public int $value { get => 42; } } function answer() { return (new Item())->value; }');
117        $application = new Application();
118        $application->setAutoExit(false);
119        $tester = new ApplicationTester($application);
120        self::assertSame(0, $tester->run(['query' => 'answer#return', '--config' => '/project/deliver.yaml', '--php-version' => '8.4'], ['capture_stderr_separately' => true]));
121        self::assertStringContainsString('"result": 42', $tester->getDisplay());
122        self::assertSame('', $tester->getErrorOutput());
123    }
124
125    #[Override]
126    protected function tearDown(): void
127    {
128        Mock::disableAll();
129        parent::tearDown();
130    }
131}
132