Skip to content

Commit 143931b

Browse files
committed
feat: Add tests for SkillsSynchronizer and SkillsCommand, including synchronization and link manipulation scenarios.
Signed-off-by: Felipe Sayão Lobato Abreu <github@mentordosnerds.com>
1 parent 5e24965 commit 143931b

4 files changed

Lines changed: 556 additions & 1 deletion

File tree

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
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+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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 FastForward\DevTools\Agent\Skills\SynchronizeResult;
22+
use PHPUnit\Framework\Attributes\CoversClass;
23+
use PHPUnit\Framework\Attributes\Test;
24+
use PHPUnit\Framework\TestCase;
25+
26+
#[CoversClass(SynchronizeResult::class)]
27+
final class SynchronizeResultTest extends TestCase
28+
{
29+
private SynchronizeResult $result;
30+
31+
/**
32+
* @return void
33+
*/
34+
protected function setUp(): void
35+
{
36+
$this->result = new SynchronizeResult();
37+
}
38+
39+
/**
40+
* @return void
41+
*/
42+
#[Test]
43+
public function newResultWillHaveEmptyListsAndNotFailed(): void
44+
{
45+
self::assertSame([], $this->result->getCreatedLinks());
46+
self::assertSame([], $this->result->getPreservedLinks());
47+
self::assertSame([], $this->result->getRemovedBrokenLinks());
48+
self::assertFalse($this->result->failed());
49+
}
50+
51+
/**
52+
* @return void
53+
*/
54+
#[Test]
55+
public function addCreatedLinkWillAddToCreatedList(): void
56+
{
57+
$this->result->addCreatedLink('skill-one');
58+
$this->result->addCreatedLink('skill-two');
59+
60+
self::assertSame(['skill-one', 'skill-two'], $this->result->getCreatedLinks());
61+
}
62+
63+
/**
64+
* @return void
65+
*/
66+
#[Test]
67+
public function addPreservedLinkWillAddToPreservedList(): void
68+
{
69+
$this->result->addPreservedLink('existing-skill');
70+
71+
self::assertSame(['existing-skill'], $this->result->getPreservedLinks());
72+
}
73+
74+
/**
75+
* @return void
76+
*/
77+
#[Test]
78+
public function addRemovedBrokenLinkWillAddToRemovedList(): void
79+
{
80+
$this->result->addRemovedBrokenLink('broken-skill');
81+
82+
self::assertSame(['broken-skill'], $this->result->getRemovedBrokenLinks());
83+
}
84+
85+
/**
86+
* @return void
87+
*/
88+
#[Test]
89+
public function markFailedWillSetFailedFlag(): void
90+
{
91+
self::assertFalse($this->result->failed());
92+
93+
$this->result->markFailed();
94+
95+
self::assertTrue($this->result->failed());
96+
}
97+
98+
/**
99+
* @return void
100+
*/
101+
#[Test]
102+
public function failedWillReturnFalseAfterMultipleOperations(): void
103+
{
104+
$this->result->addCreatedLink('new-skill');
105+
$this->result->addPreservedLink('old-skill');
106+
$this->result->addRemovedBrokenLink('broken-skill');
107+
108+
self::assertFalse($this->result->failed());
109+
self::assertSame(['new-skill'], $this->result->getCreatedLinks());
110+
self::assertSame(['old-skill'], $this->result->getPreservedLinks());
111+
self::assertSame(['broken-skill'], $this->result->getRemovedBrokenLinks());
112+
}
113+
}

0 commit comments

Comments
 (0)