packages/deriver/tests/Unit/Deriver/Sources/CollectorTest.php

1<?php
2
3declare (strict_types=1);
4
5namespace Tests\Unit\Deriver\Sources;
6
7use Deriver\Deriver\Sources\Collector;
8use Deriver\Deriver\Sources\Path;
9use Deriver\Deriver\Sources\Source;
10use Deriver\Deriver\Sources\Sources;
11use Deriver\Exception\CollectorException;
12use org\bovigo\vfs\vfsStream;
13use Override;
14use phpmock\Mock;
15use phpmock\MockBuilder;
16use PHPUnit\Framework\Attributes\CoversClass;
17use PHPUnit\Framework\Attributes\DataProvider;
18use PHPUnit\Framework\Attributes\PreserveGlobalState;
19use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
20use PHPUnit\Framework\Attributes\Small;
21use PHPUnit\Framework\Attributes\UsesClass;
22use PHPUnit\Framework\TestCase;
23
24#[CoversClass(Collector::class)]
25#[UsesClass(Path::class)]
26#[UsesClass(Source::class)]
27#[UsesClass(Sources::class)]
28#[Small]
29#[RunTestsInSeparateProcesses]
30#[PreserveGlobalState(false)]
31final class CollectorTest extends TestCase
32{
33    /**
34     * @throws \phpmock\MockEnabledException
35     */
36    public function testCollectReadsOnlySelectedFilesInCallerOrderWithoutExecutingOrParsing(): void
37    {
38        $workingDirectory = '/project';
39        $filesystem = vfsStream::setup('project');
40        (new MockBuilder())->setNamespace('Deriver\Deriver\Sources')->setName('is_file')->setFunction(static function (string $path) use (&$workingDirectory): bool {
41            return is_file('vfs:/' . Path::normalize(str_starts_with($path, '/') ? $path : $workingDirectory . '/' . $path));
42        })->build()->enable();
43        (new MockBuilder())->setNamespace('Deriver\Deriver\Sources')->setName('is_readable')->setFunction(static function (string $path) use (&$workingDirectory): bool {
44            return is_readable('vfs:/' . Path::normalize(str_starts_with($path, '/') ? $path : $workingDirectory . '/' . $path));
45        })->build()->enable();
46        (new MockBuilder())->setNamespace('Deriver\Deriver\Sources')->setName('file_get_contents')->setFunction(static function (string $path) use (&$workingDirectory): string|false {
47            return file_get_contents('vfs:/' . Path::normalize(str_starts_with($path, '/') ? $path : $workingDirectory . '/' . $path));
48        })->build()->enable();
49        (new MockBuilder())->setNamespace('Deriver\Deriver\Selection')->setName('getcwd')->setFunction(static function () use (&$workingDirectory): string {
50            return $workingDirectory;
51        })->build()->enable();
52        $directory = $filesystem;
53        $throwing = '<?php throw new RuntimeException("must not execute");';
54        $malformed = "<?php function invalid( \xff";
55        $name = 'z.php';
56        $url = $directory->url() . '/' . $name;
57        file_put_contents($url, $throwing);
58        $last = '/' . $directory->path() . '/' . $name;
59        $name1 = 'a.php';
60        $url1 = $directory->url() . '/' . $name1;
61        file_put_contents($url1, $malformed);
62        $first = '/' . $directory->path() . '/' . $name1;
63        $name2 = 'unselected.php';
64        $contents = '<?php exit(1);';
65        $url2 = $directory->url() . '/' . $name2;
66        file_put_contents($url2, $contents);
67        $sources = (new Collector())->collect([$last, $first]);
68        self::assertCount(2, $sources);
69        self::assertSame($last, $sources[0]->path);
70        self::assertSame($throwing, $sources[0]->contents);
71        self::assertFalse($sources[0]->declarationsOnly);
72        self::assertSame($first, $sources[1]->path);
73        self::assertSame($malformed, $sources[1]->contents);
74    }
75    /**
76     * @throws \phpmock\MockEnabledException
77     */
78    public function testCollectAcceptsAnEmptySelection(): void
79    {
80        $workingDirectory = '/project';
81        $filesystem = vfsStream::setup('project');
82        (new MockBuilder())->setNamespace('Deriver\Deriver\Sources')->setName('is_file')->setFunction(static function (string $path) use (&$workingDirectory): bool {
83            return is_file('vfs:/' . Path::normalize(str_starts_with($path, '/') ? $path : $workingDirectory . '/' . $path));
84        })->build()->enable();
85        (new MockBuilder())->setNamespace('Deriver\Deriver\Sources')->setName('is_readable')->setFunction(static function (string $path) use (&$workingDirectory): bool {
86            return is_readable('vfs:/' . Path::normalize(str_starts_with($path, '/') ? $path : $workingDirectory . '/' . $path));
87        })->build()->enable();
88        (new MockBuilder())->setNamespace('Deriver\Deriver\Sources')->setName('file_get_contents')->setFunction(static function (string $path) use (&$workingDirectory): string|false {
89            return file_get_contents('vfs:/' . Path::normalize(str_starts_with($path, '/') ? $path : $workingDirectory . '/' . $path));
90        })->build()->enable();
91        (new MockBuilder())->setNamespace('Deriver\Deriver\Selection')->setName('getcwd')->setFunction(static function () use (&$workingDirectory): string {
92            return $workingDirectory;
93        })->build()->enable();
94        self::assertCount(0, (new Collector())->collect([]));
95    }
96    /**
97     * @throws \phpmock\MockEnabledException
98     */
99    public function testCollectCapturesFreshContentsAndPreservesEarlierSourcesAfterDeletion(): void
100    {
101        $workingDirectory = '/project';
102        $filesystem = vfsStream::setup('project');
103        (new MockBuilder())->setNamespace('Deriver\Deriver\Sources')->setName('is_file')->setFunction(static function (string $path) use (&$workingDirectory): bool {
104            return is_file('vfs:/' . Path::normalize(str_starts_with($path, '/') ? $path : $workingDirectory . '/' . $path));
105        })->build()->enable();
106        (new MockBuilder())->setNamespace('Deriver\Deriver\Sources')->setName('is_readable')->setFunction(static function (string $path) use (&$workingDirectory): bool {
107            return is_readable('vfs:/' . Path::normalize(str_starts_with($path, '/') ? $path : $workingDirectory . '/' . $path));
108        })->build()->enable();
109        (new MockBuilder())->setNamespace('Deriver\Deriver\Sources')->setName('file_get_contents')->setFunction(static function (string $path) use (&$workingDirectory): string|false {
110            return file_get_contents('vfs:/' . Path::normalize(str_starts_with($path, '/') ? $path : $workingDirectory . '/' . $path));
111        })->build()->enable();
112        (new MockBuilder())->setNamespace('Deriver\Deriver\Selection')->setName('getcwd')->setFunction(static function () use (&$workingDirectory): string {
113            return $workingDirectory;
114        })->build()->enable();
115        $directory = $filesystem;
116        $name = 'source.php';
117        $contents = 'first';
118        $url = $directory->url() . '/' . $name;
119        file_put_contents($url, $contents);
120        $path = '/' . $directory->path() . '/' . $name;
121        $collector = new Collector();
122        $first = $collector->collect([$path]);
123        $name1 = 'source.php';
124        $contents1 = 'second';
125        $url1 = $directory->url() . '/' . $name1;
126        file_put_contents($url1, $contents1);
127        $second = $collector->collect([$path]);
128        $path1 = $path;
129        $path1 = str_starts_with($path1, '/') ? $path1 : $workingDirectory . '/' . $path1;
130        self::assertTrue(unlink('vfs:/' . Path::normalize($path1)));
131        self::assertSame('first', $first[0]->contents);
132        self::assertSame('second', $second[0]->contents);
133    }
134    /**
135     * @throws \phpmock\MockEnabledException
136     */
137    public function testCollectNormalizesSourcePaths(): void
138    {
139        $workingDirectory = '/project';
140        $filesystem = vfsStream::setup('project', null, ['nested' => []]);
141        (new MockBuilder())->setNamespace('Deriver\Deriver\Sources')->setName('is_file')->setFunction(static function (string $path) use (&$workingDirectory): bool {
142            return is_file('vfs:/' . Path::normalize(str_starts_with($path, '/') ? $path : $workingDirectory . '/' . $path));
143        })->build()->enable();
144        (new MockBuilder())->setNamespace('Deriver\Deriver\Sources')->setName('is_readable')->setFunction(static function (string $path) use (&$workingDirectory): bool {
145            return is_readable('vfs:/' . Path::normalize(str_starts_with($path, '/') ? $path : $workingDirectory . '/' . $path));
146        })->build()->enable();
147        (new MockBuilder())->setNamespace('Deriver\Deriver\Sources')->setName('file_get_contents')->setFunction(static function (string $path) use (&$workingDirectory): string|false {
148            return file_get_contents('vfs:/' . Path::normalize(str_starts_with($path, '/') ? $path : $workingDirectory . '/' . $path));
149        })->build()->enable();
150        (new MockBuilder())->setNamespace('Deriver\Deriver\Selection')->setName('getcwd')->setFunction(static function () use (&$workingDirectory): string {
151            return $workingDirectory;
152        })->build()->enable();
153        $directory = $filesystem;
154        $name = 'nested/source.php';
155        $contents = '';
156        $url = $directory->url() . '/' . $name;
157        file_put_contents($url, $contents);
158        $path = '/' . $directory->path() . '/' . $name;
159        $sources = (new Collector())->collect(['/' . $directory->path() . '/nested/../nested/./source.php']);
160        self::assertSame($path, $sources[0]->path);
161        self::assertSame('', $sources[0]->contents);
162    }
163    /**
164     * @throws \phpmock\MockEnabledException
165     */
166    public function testCollectRejectsRepeatedPathsAfterNormalization(): void
167    {
168        $workingDirectory = '/project';
169        $filesystem = vfsStream::setup('project');
170        (new MockBuilder())->setNamespace('Deriver\Deriver\Sources')->setName('is_file')->setFunction(static function (string $path) use (&$workingDirectory): bool {
171            return is_file('vfs:/' . Path::normalize(str_starts_with($path, '/') ? $path : $workingDirectory . '/' . $path));
172        })->build()->enable();
173        (new MockBuilder())->setNamespace('Deriver\Deriver\Sources')->setName('is_readable')->setFunction(static function (string $path) use (&$workingDirectory): bool {
174            return is_readable('vfs:/' . Path::normalize(str_starts_with($path, '/') ? $path : $workingDirectory . '/' . $path));
175        })->build()->enable();
176        (new MockBuilder())->setNamespace('Deriver\Deriver\Sources')->setName('file_get_contents')->setFunction(static function (string $path) use (&$workingDirectory): string|false {
177            return file_get_contents('vfs:/' . Path::normalize(str_starts_with($path, '/') ? $path : $workingDirectory . '/' . $path));
178        })->build()->enable();
179        (new MockBuilder())->setNamespace('Deriver\Deriver\Selection')->setName('getcwd')->setFunction(static function () use (&$workingDirectory): string {
180            return $workingDirectory;
181        })->build()->enable();
182        $directory = $filesystem;
183        $name = 'source.php';
184        $contents = '<?php';
185        $url = $directory->url() . '/' . $name;
186        file_put_contents($url, $contents);
187        $path = '/' . $directory->path() . '/' . $name;
188        $this->expectException(CollectorException::class);
189        $this->expectExceptionMessage('Source paths must be nonempty and unique:');
190        (new Collector())->collect([$path, '/' . $directory->path() . '/./source.php']);
191    }
192    /**
193     * @throws \phpmock\MockEnabledException
194     */
195    #[DataProvider('providerUnreadableFiles')]
196    public function testCollectRejectsWrappersDirectoriesAndMissingFiles(string $path): void
197    {
198        $workingDirectory = '/project';
199        $filesystem = vfsStream::setup('project');
200        (new MockBuilder())->setNamespace('Deriver\Deriver\Sources')->setName('is_file')->setFunction(static function (string $path) use (&$workingDirectory): bool {
201            return is_file('vfs:/' . Path::normalize(str_starts_with($path, '/') ? $path : $workingDirectory . '/' . $path));
202        })->build()->enable();
203        (new MockBuilder())->setNamespace('Deriver\Deriver\Sources')->setName('is_readable')->setFunction(static function (string $path) use (&$workingDirectory): bool {
204            return is_readable('vfs:/' . Path::normalize(str_starts_with($path, '/') ? $path : $workingDirectory . '/' . $path));
205        })->build()->enable();
206        (new MockBuilder())->setNamespace('Deriver\Deriver\Sources')->setName('file_get_contents')->setFunction(static function (string $path) use (&$workingDirectory): string|false {
207            return file_get_contents('vfs:/' . Path::normalize(str_starts_with($path, '/') ? $path : $workingDirectory . '/' . $path));
208        })->build()->enable();
209        (new MockBuilder())->setNamespace('Deriver\Deriver\Selection')->setName('getcwd')->setFunction(static function () use (&$workingDirectory): string {
210            return $workingDirectory;
211        })->build()->enable();
212        $this->expectException(CollectorException::class);
213        $this->expectExceptionMessage('Source must be a readable local file:');
214        (new Collector())->collect([$path]);
215    }
216    /**
217     * @return iterable<string, array{string}>
218     */
219    public static function providerUnreadableFiles(): iterable
220    {
221        yield 'wrapper' => ['php://memory'];
222        yield 'readable local wrapper' => ['file://' . __FILE__];
223        yield 'directory' => [__DIR__];
224        yield 'missing' => [__DIR__ . '/does-not-exist.php'];
225    }
226    #[Override]
227    protected function tearDown(): void
228    {
229        Mock::disableAll();
230        parent::tearDown();
231    }
232}
233