|
| 1 | +<?php |
| 2 | + |
| 3 | +declare(strict_types=1); |
| 4 | + |
| 5 | +/** |
| 6 | + * This file is part of fast-forward/dev-tools. |
| 7 | + * |
| 8 | + * This source file is subject to the license bundled |
| 9 | + * with this source code in the file LICENSE. |
| 10 | + * |
| 11 | + * @copyright Copyright (c) 2026 Felipe Sayão Lobato Abreu <github@mentordosnerds.com> |
| 12 | + * @license https://opensource.org/licenses/MIT MIT License |
| 13 | + * |
| 14 | + * @see https://github.com/php-fast-forward/dev-tools |
| 15 | + * @see https://github.com/php-fast-forward |
| 16 | + * @see https://datatracker.ietf.org/doc/html/rfc2119 |
| 17 | + */ |
| 18 | + |
| 19 | +namespace FastForward\DevTools\Tests\Agent\Skills; |
| 20 | + |
| 21 | +use ArrayIterator; |
| 22 | +use FastForward\DevTools\Agent\Skills\SkillsSynchronizer; |
| 23 | +use FastForward\DevTools\Agent\Skills\SynchronizeResult; |
| 24 | +use PHPUnit\Framework\Attributes\CoversClass; |
| 25 | +use PHPUnit\Framework\Attributes\Test; |
| 26 | +use PHPUnit\Framework\Attributes\UsesClass; |
| 27 | +use PHPUnit\Framework\TestCase; |
| 28 | +use Prophecy\PhpUnit\ProphecyTrait; |
| 29 | +use Prophecy\Prophecy\ObjectProphecy; |
| 30 | +use Psr\Log\LoggerInterface; |
| 31 | +use Symfony\Component\Filesystem\Filesystem; |
| 32 | +use Symfony\Component\Finder\Finder; |
| 33 | +use Symfony\Component\Finder\SplFileInfo; |
| 34 | + |
| 35 | +#[CoversClass(SkillsSynchronizer::class)] |
| 36 | +#[UsesClass(SynchronizeResult::class)] |
| 37 | +final class SkillsSynchronizerTest extends TestCase |
| 38 | +{ |
| 39 | + use ProphecyTrait; |
| 40 | + |
| 41 | + private const string PACKAGE_SKILLS_PATH = '/package/.agents/skills'; |
| 42 | + |
| 43 | + private const string CONSUMER_SKILLS_PATH = '/consumer/.agents/skills'; |
| 44 | + |
| 45 | + /** |
| 46 | + * @var ObjectProphecy<Filesystem> |
| 47 | + */ |
| 48 | + private ObjectProphecy $filesystem; |
| 49 | + |
| 50 | + /** |
| 51 | + * @var ObjectProphecy<Finder> |
| 52 | + */ |
| 53 | + private ObjectProphecy $finder; |
| 54 | + |
| 55 | + /** |
| 56 | + * @var ObjectProphecy<LoggerInterface> |
| 57 | + */ |
| 58 | + private ObjectProphecy $logger; |
| 59 | + |
| 60 | + /** |
| 61 | + * @return void |
| 62 | + */ |
| 63 | + protected function setUp(): void |
| 64 | + { |
| 65 | + $this->filesystem = $this->prophesize(Filesystem::class); |
| 66 | + $this->finder = $this->prophesize(Finder::class); |
| 67 | + $this->logger = $this->prophesize(LoggerInterface::class); |
| 68 | + } |
| 69 | + |
| 70 | + /** |
| 71 | + * @return void |
| 72 | + */ |
| 73 | + #[Test] |
| 74 | + public function synchronizeWithMissingPackagePathWillReturnFailedResult(): void |
| 75 | + { |
| 76 | + $this->filesystem->exists(self::PACKAGE_SKILLS_PATH)->willReturn(false); |
| 77 | + $this->logger->error('No packaged skills found at: ' . self::PACKAGE_SKILLS_PATH)->shouldBeCalledOnce(); |
| 78 | + |
| 79 | + $result = $this->createSynchronizer() |
| 80 | + ->synchronize(self::CONSUMER_SKILLS_PATH, self::PACKAGE_SKILLS_PATH); |
| 81 | + |
| 82 | + self::assertTrue($result->failed()); |
| 83 | + self::assertSame([], $result->getCreatedLinks()); |
| 84 | + } |
| 85 | + |
| 86 | + /** |
| 87 | + * @return void |
| 88 | + */ |
| 89 | + #[Test] |
| 90 | + public function synchronizeWithMissingSkillsDirWillCreateItAndCreateLinks(): void |
| 91 | + { |
| 92 | + $skillOnePath = self::PACKAGE_SKILLS_PATH . '/skill-one'; |
| 93 | + $skillTwoPath = self::PACKAGE_SKILLS_PATH . '/skill-two'; |
| 94 | + |
| 95 | + $this->mockFinder( |
| 96 | + $this->createSkillDirectory('skill-one', $skillOnePath), |
| 97 | + $this->createSkillDirectory('skill-two', $skillTwoPath), |
| 98 | + ); |
| 99 | + |
| 100 | + $this->filesystem->exists(self::PACKAGE_SKILLS_PATH)->willReturn(true); |
| 101 | + $this->filesystem->exists(self::CONSUMER_SKILLS_PATH)->willReturn(false); |
| 102 | + $this->filesystem->mkdir(self::CONSUMER_SKILLS_PATH)->shouldBeCalledOnce(); |
| 103 | + $this->logger->info('Created .agents/skills directory.') |
| 104 | + ->shouldBeCalledOnce(); |
| 105 | + |
| 106 | + $this->filesystem->exists(self::CONSUMER_SKILLS_PATH . '/skill-one')->willReturn(false); |
| 107 | + $this->filesystem->exists(self::CONSUMER_SKILLS_PATH . '/skill-two')->willReturn(false); |
| 108 | + $this->filesystem->symlink($skillOnePath, self::CONSUMER_SKILLS_PATH . '/skill-one')->shouldBeCalledOnce(); |
| 109 | + $this->filesystem->symlink($skillTwoPath, self::CONSUMER_SKILLS_PATH . '/skill-two')->shouldBeCalledOnce(); |
| 110 | + $this->logger->info('Created link: skill-one -> ' . $skillOnePath)->shouldBeCalledOnce(); |
| 111 | + $this->logger->info('Created link: skill-two -> ' . $skillTwoPath)->shouldBeCalledOnce(); |
| 112 | + |
| 113 | + $result = $this->createSynchronizer() |
| 114 | + ->synchronize(self::CONSUMER_SKILLS_PATH, self::PACKAGE_SKILLS_PATH); |
| 115 | + |
| 116 | + self::assertFalse($result->failed()); |
| 117 | + self::assertSame(['skill-one', 'skill-two'], $result->getCreatedLinks()); |
| 118 | + self::assertSame([], $result->getPreservedLinks()); |
| 119 | + self::assertSame([], $result->getRemovedBrokenLinks()); |
| 120 | + } |
| 121 | + |
| 122 | + /** |
| 123 | + * @return void |
| 124 | + */ |
| 125 | + #[Test] |
| 126 | + public function synchronizeWillPreserveExistingValidSymlink(): void |
| 127 | + { |
| 128 | + $skillOnePath = self::PACKAGE_SKILLS_PATH . '/skill-one'; |
| 129 | + $targetLink = self::CONSUMER_SKILLS_PATH . '/skill-one'; |
| 130 | + |
| 131 | + $this->mockFinder($this->createSkillDirectory('skill-one', $skillOnePath)); |
| 132 | + |
| 133 | + $this->filesystem->exists(self::PACKAGE_SKILLS_PATH)->willReturn(true); |
| 134 | + $this->filesystem->exists(self::CONSUMER_SKILLS_PATH)->willReturn(true); |
| 135 | + $this->filesystem->exists($targetLink) |
| 136 | + ->willReturn(true); |
| 137 | + $this->filesystem->readlink($targetLink) |
| 138 | + ->willReturn($skillOnePath); |
| 139 | + $this->filesystem->readlink($targetLink, true) |
| 140 | + ->willReturn($skillOnePath); |
| 141 | + $this->filesystem->exists($skillOnePath) |
| 142 | + ->willReturn(true); |
| 143 | + $this->logger->notice('Preserved existing link: skill-one') |
| 144 | + ->shouldBeCalledOnce(); |
| 145 | + |
| 146 | + $result = $this->createSynchronizer() |
| 147 | + ->synchronize(self::CONSUMER_SKILLS_PATH, self::PACKAGE_SKILLS_PATH); |
| 148 | + |
| 149 | + self::assertFalse($result->failed()); |
| 150 | + self::assertSame([], $result->getCreatedLinks()); |
| 151 | + self::assertSame(['skill-one'], $result->getPreservedLinks()); |
| 152 | + } |
| 153 | + |
| 154 | + /** |
| 155 | + * @return void |
| 156 | + */ |
| 157 | + #[Test] |
| 158 | + public function synchronizeWillHandleExistingBrokenSymlink(): void |
| 159 | + { |
| 160 | + $skillOnePath = self::PACKAGE_SKILLS_PATH . '/skill-one'; |
| 161 | + $targetLink = self::CONSUMER_SKILLS_PATH . '/skill-one'; |
| 162 | + $brokenLinkPath = '/obsolete/.agents/skills/skill-one'; |
| 163 | + |
| 164 | + $this->mockFinder($this->createSkillDirectory('skill-one', $skillOnePath)); |
| 165 | + |
| 166 | + $this->filesystem->exists(self::PACKAGE_SKILLS_PATH)->willReturn(true); |
| 167 | + $this->filesystem->exists(self::CONSUMER_SKILLS_PATH)->willReturn(true); |
| 168 | + $this->filesystem->exists($targetLink) |
| 169 | + ->willReturn(true); |
| 170 | + $this->filesystem->readlink($targetLink) |
| 171 | + ->willReturn($brokenLinkPath); |
| 172 | + $this->filesystem->readlink($targetLink, true) |
| 173 | + ->willReturn($brokenLinkPath); |
| 174 | + $this->filesystem->exists($brokenLinkPath) |
| 175 | + ->willReturn(false); |
| 176 | + $this->filesystem->remove($targetLink) |
| 177 | + ->shouldBeCalledOnce(); |
| 178 | + $this->filesystem->symlink($skillOnePath, $targetLink) |
| 179 | + ->shouldBeCalledOnce(); |
| 180 | + $this->logger->notice('Existing link is broken: skill-one (removing and recreating)') |
| 181 | + ->shouldBeCalledOnce(); |
| 182 | + $this->logger->info('Created link: skill-one -> ' . $skillOnePath)->shouldBeCalledOnce(); |
| 183 | + |
| 184 | + $result = $this->createSynchronizer() |
| 185 | + ->synchronize(self::CONSUMER_SKILLS_PATH, self::PACKAGE_SKILLS_PATH); |
| 186 | + |
| 187 | + self::assertFalse($result->failed()); |
| 188 | + self::assertSame(['skill-one'], $result->getCreatedLinks()); |
| 189 | + self::assertSame([], $result->getPreservedLinks()); |
| 190 | + self::assertSame(['skill-one'], $result->getRemovedBrokenLinks()); |
| 191 | + } |
| 192 | + |
| 193 | + /** |
| 194 | + * @return void |
| 195 | + */ |
| 196 | + #[Test] |
| 197 | + public function synchronizeWillPreserveNonSymlinkDirectoryForSameSkill(): void |
| 198 | + { |
| 199 | + $skillOnePath = self::PACKAGE_SKILLS_PATH . '/skill-one'; |
| 200 | + $targetLink = self::CONSUMER_SKILLS_PATH . '/skill-one'; |
| 201 | + |
| 202 | + $this->mockFinder($this->createSkillDirectory('skill-one', $skillOnePath)); |
| 203 | + |
| 204 | + $this->filesystem->exists(self::PACKAGE_SKILLS_PATH)->willReturn(true); |
| 205 | + $this->filesystem->exists(self::CONSUMER_SKILLS_PATH)->willReturn(true); |
| 206 | + $this->filesystem->exists($targetLink) |
| 207 | + ->willReturn(true); |
| 208 | + $this->filesystem->readlink($targetLink) |
| 209 | + ->willReturn(null); |
| 210 | + $this->logger->notice( |
| 211 | + 'Existing non-symlink found: skill-one (keeping as is, skipping link creation)' |
| 212 | + )->shouldBeCalledOnce(); |
| 213 | + |
| 214 | + $result = $this->createSynchronizer() |
| 215 | + ->synchronize(self::CONSUMER_SKILLS_PATH, self::PACKAGE_SKILLS_PATH); |
| 216 | + |
| 217 | + self::assertFalse($result->failed()); |
| 218 | + self::assertSame([], $result->getCreatedLinks()); |
| 219 | + self::assertSame(['skill-one'], $result->getPreservedLinks()); |
| 220 | + self::assertSame([], $result->getRemovedBrokenLinks()); |
| 221 | + } |
| 222 | + |
| 223 | + /** |
| 224 | + * @param SplFileInfo ...$skills |
| 225 | + * |
| 226 | + * @return void |
| 227 | + */ |
| 228 | + private function mockFinder(SplFileInfo ...$skills): void |
| 229 | + { |
| 230 | + $finder = $this->finder->reveal(); |
| 231 | + |
| 232 | + $this->finder->directories() |
| 233 | + ->willReturn($finder) |
| 234 | + ->shouldBeCalledOnce(); |
| 235 | + $this->finder->in(self::PACKAGE_SKILLS_PATH)->willReturn($finder)->shouldBeCalledOnce(); |
| 236 | + $this->finder->depth('== 0') |
| 237 | + ->willReturn($finder) |
| 238 | + ->shouldBeCalledOnce(); |
| 239 | + $this->finder->getIterator() |
| 240 | + ->willReturn(new ArrayIterator($skills)); |
| 241 | + } |
| 242 | + |
| 243 | + /** |
| 244 | + * @param string $skillName |
| 245 | + * @param string $sourcePath |
| 246 | + * |
| 247 | + * @return SplFileInfo |
| 248 | + */ |
| 249 | + private function createSkillDirectory(string $skillName, string $sourcePath): SplFileInfo |
| 250 | + { |
| 251 | + $skillDirectory = $this->prophesize(SplFileInfo::class); |
| 252 | + $skillDirectory->getFilename() |
| 253 | + ->willReturn($skillName); |
| 254 | + $skillDirectory->getRealPath() |
| 255 | + ->willReturn($sourcePath); |
| 256 | + |
| 257 | + return $skillDirectory->reveal(); |
| 258 | + } |
| 259 | + |
| 260 | + /** |
| 261 | + * @return SkillsSynchronizer |
| 262 | + */ |
| 263 | + private function createSynchronizer(): SkillsSynchronizer |
| 264 | + { |
| 265 | + $synchronizer = new SkillsSynchronizer($this->filesystem->reveal(), $this->finder->reveal()); |
| 266 | + $synchronizer->setLogger($this->logger->reveal()); |
| 267 | + |
| 268 | + return $synchronizer; |
| 269 | + } |
| 270 | +} |
0 commit comments