From ce06212591a092bd166d58e0482dcdbf90d8d4dd Mon Sep 17 00:00:00 2001 From: Fred Myerscough Date: Fri, 30 Oct 2026 17:23:26 +0000 Subject: [PATCH 1/4] docs(readme): point test badge at renamed ci.yml workflow --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7513f21..c13bf5a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Total Downloads](https://poser.pugx.org/myerscode/beacon/downloads)](https://packagist.org/packages/myerscode/beacon) [![PHP Version Require](http://poser.pugx.org/myerscode/beacon/require/php)](https://packagist.org/packages/myerscode/beacon) [![License](https://poser.pugx.org/myerscode/beacon/license)](https://github.com/myerscode/beacon/blob/main/LICENSE) -[![Tests](https://github.com/myerscode/beacon/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/myerscode/beacon/actions/workflows/tests.yml) +[![Tests](https://github.com/myerscode/beacon/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/myerscode/beacon/actions/workflows/tests.yml) [![codecov](https://codecov.io/gh/myerscode/beacon/graph/badge.svg)](https://codecov.io/gh/myerscode/beacon) # Beacon From 5145a7bad7c087a2d391b65d69280a7a1888e4e5 Mon Sep 17 00:00:00 2001 From: Fred Myerscough Date: Sat, 31 Oct 2026 00:18:13 +0000 Subject: [PATCH 2/4] chore: temp pint autofix workflow --- .github/workflows/_pintfix.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .github/workflows/_pintfix.yml diff --git a/.github/workflows/_pintfix.yml b/.github/workflows/_pintfix.yml new file mode 100644 index 0000000..e2410c9 --- /dev/null +++ b/.github/workflows/_pintfix.yml @@ -0,0 +1,14 @@ +name: Pint Fix (temp) +on: push +permissions: + contents: write +jobs: + fix: + permissions: + contents: write + uses: myerscode/workflows/.github/workflows/php-lint.yml@v1 + with: + php-version: "8.5" + extensions: "curl, dom, json, mbstring, xml, zip" + fix: true + analyse: false From d0f063ec6cc2fa664ea47756e17fe54bb444f03f Mon Sep 17 00:00:00 2001 From: Fred Myerscough Date: Sat, 31 Oct 2026 00:20:07 +0000 Subject: [PATCH 3/4] style: apply pint code style fixes --- .github/workflows/_pintfix.yml | 14 - src/Crawler/CrawlConfig.php | 70 ++-- src/Crawler/JitterStrategy.php | 2 +- src/Driver/ChromeDriverInstaller.php | 132 +++---- src/Driver/HttpClientInterface.php | 4 +- tests/Crawler/CrawlConfigTest.php | 193 +++++----- tests/Crawler/SpiderTest.php | 333 +++++++++--------- .../ChromeDriverInstallerDownloadTest.php | 159 ++++----- tests/PageLighthouseTest.php | 33 +- 9 files changed, 463 insertions(+), 477 deletions(-) delete mode 100644 .github/workflows/_pintfix.yml diff --git a/.github/workflows/_pintfix.yml b/.github/workflows/_pintfix.yml deleted file mode 100644 index e2410c9..0000000 --- a/.github/workflows/_pintfix.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: Pint Fix (temp) -on: push -permissions: - contents: write -jobs: - fix: - permissions: - contents: write - uses: myerscode/workflows/.github/workflows/php-lint.yml@v1 - with: - php-version: "8.5" - extensions: "curl, dom, json, mbstring, xml, zip" - fix: true - analyse: false diff --git a/src/Crawler/CrawlConfig.php b/src/Crawler/CrawlConfig.php index 6603648..c9655b1 100644 --- a/src/Crawler/CrawlConfig.php +++ b/src/Crawler/CrawlConfig.php @@ -42,6 +42,33 @@ class CrawlConfig private int $timeout = 30; + /** + * Calculate the request delay for a single request, applying jitter if configured. + * Returns 0 if no request delay is set. + */ + public function calculateRequestDelay(): int + { + if ($this->requestDelay <= 0) { + return 0; + } + + return $this->applyJitter($this->requestDelay, $this->requestJitter); + } + + /** + * Calculate the retry delay for a given attempt, applying exponential backoff and jitter. + * + * Attempt 1: retryDelay * backoffRate^0 + * Attempt 2: retryDelay * backoffRate^1 + * Attempt N: retryDelay * backoffRate^(N-1) + */ + public function calculateRetryDelay(int $attempt): int + { + $delay = (int) round($this->retryDelay * ($this->retryBackoff ** ($attempt - 1))); + + return $this->applyJitter($delay, $this->retryJitter); + } + /** * @param string[] $patterns URL patterns to exclude (matched with str_contains) */ @@ -80,33 +107,6 @@ public function getRequestDelay(): int return $this->requestDelay; } - /** - * Calculate the request delay for a single request, applying jitter if configured. - * Returns 0 if no request delay is set. - */ - public function calculateRequestDelay(): int - { - if ($this->requestDelay <= 0) { - return 0; - } - - return $this->applyJitter($this->requestDelay, $this->requestJitter); - } - - /** - * Calculate the retry delay for a given attempt, applying exponential backoff and jitter. - * - * Attempt 1: retryDelay * backoffRate^0 - * Attempt 2: retryDelay * backoffRate^1 - * Attempt N: retryDelay * backoffRate^(N-1) - */ - public function calculateRetryDelay(int $attempt): int - { - $delay = (int) round($this->retryDelay * ($this->retryBackoff ** ($attempt - 1))); - - return $this->applyJitter($delay, $this->retryJitter); - } - public function getRequestJitter(): JitterStrategy { return $this->requestJitter; @@ -227,23 +227,23 @@ public function requestWithJitter(JitterStrategy $strategy = JitterStrategy::FUL } /** - * Set the base delay in milliseconds between retry attempts (default: 1000ms). - * Combined with backoff rate, this determines the wait before each retry. + * Set the backoff multiplier for retry delays (default: 2.0). + * Each retry waits retryDelay * backoffRate^(attempt-1) milliseconds. */ - public function retryDelay(int $milliseconds): self + public function retryBackoff(float $rate): self { - $this->retryDelay = $milliseconds; + $this->retryBackoff = $rate; return $this; } /** - * Set the backoff multiplier for retry delays (default: 2.0). - * Each retry waits retryDelay * backoffRate^(attempt-1) milliseconds. + * Set the base delay in milliseconds between retry attempts (default: 1000ms). + * Combined with backoff rate, this determines the wait before each retry. */ - public function retryBackoff(float $rate): self + public function retryDelay(int $milliseconds): self { - $this->retryBackoff = $rate; + $this->retryDelay = $milliseconds; return $this; } diff --git a/src/Crawler/JitterStrategy.php b/src/Crawler/JitterStrategy.php index 3287cdb..bc934e9 100644 --- a/src/Crawler/JitterStrategy.php +++ b/src/Crawler/JitterStrategy.php @@ -14,6 +14,6 @@ */ enum JitterStrategy: string { - case NONE = 'none'; case FULL = 'full'; + case NONE = 'none'; } diff --git a/src/Driver/ChromeDriverInstaller.php b/src/Driver/ChromeDriverInstaller.php index b07604c..e2fc255 100644 --- a/src/Driver/ChromeDriverInstaller.php +++ b/src/Driver/ChromeDriverInstaller.php @@ -35,6 +35,11 @@ public static function make(): static return new static(); } + public function binaryName(): string + { + return PHP_OS_FAMILY === 'Windows' ? 'chromedriver.exe' : 'chromedriver'; + } + public function clean(?string $driversDir = null): InstallationResult { $driversDir = $this->resolveDriversDir($driversDir); @@ -49,72 +54,6 @@ public function clean(?string $driversDir = null): InstallationResult return InstallationResult::nothing(sprintf('ChromeDriver not found in %s, nothing to clean.', $driversDir)); } - public function getChromeVersion(): ?string - { - $candidates = PHP_OS_FAMILY === 'Windows' - ? $this->windowsChromePaths() - : $this->unixChromePaths(); - - foreach ($candidates as $candidate) { - $version = $this->getBinaryVersion($candidate); - - if ($version !== null) { - return $version; - } - } - - $finder = new ExecutableFinder(); - $names = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'chrome']; - - foreach ($names as $name) { - $found = $finder->find($name); - - if ($found !== null) { - $version = $this->getBinaryVersion($found); - - if ($version !== null) { - return $version; - } - } - } - - return null; - } - - public function install(bool $force = false, ?string $driversDir = null): InstallationResult - { - $driversDir = $this->resolveDriversDir($driversDir); - $binary = $driversDir.DIRECTORY_SEPARATOR.$this->binaryName(); - - if (! $force && file_exists($binary)) { - $installedVersion = $this->getBinaryVersion($binary); - $chromeVersion = $this->getChromeVersion(); - - if ($installedVersion !== null && $chromeVersion !== null) { - $installedMajor = (int) explode('.', $installedVersion)[0]; - $chromeMajor = (int) explode('.', $chromeVersion)[0]; - - if ($installedMajor === $chromeMajor) { - return InstallationResult::skipped( - sprintf('ChromeDriver %s already installed and matches Chrome %s. Skipping.', $installedVersion, $chromeVersion), - ); - } - } - } - - return $this->download($driversDir); - } - - public function update(?string $driversDir = null): InstallationResult - { - return $this->install(force: true, driversDir: $driversDir); - } - - public function binaryName(): string - { - return PHP_OS_FAMILY === 'Windows' ? 'chromedriver.exe' : 'chromedriver'; - } - public function download(string $driversDir): InstallationResult { $chromeVersion = $this->getChromeVersion(); @@ -193,6 +132,62 @@ public function getBinaryVersion(string $binary): ?string return null; } + public function getChromeVersion(): ?string + { + $candidates = PHP_OS_FAMILY === 'Windows' + ? $this->windowsChromePaths() + : $this->unixChromePaths(); + + foreach ($candidates as $candidate) { + $version = $this->getBinaryVersion($candidate); + + if ($version !== null) { + return $version; + } + } + + $finder = new ExecutableFinder(); + $names = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'chrome']; + + foreach ($names as $name) { + $found = $finder->find($name); + + if ($found !== null) { + $version = $this->getBinaryVersion($found); + + if ($version !== null) { + return $version; + } + } + } + + return null; + } + + public function install(bool $force = false, ?string $driversDir = null): InstallationResult + { + $driversDir = $this->resolveDriversDir($driversDir); + $binary = $driversDir.DIRECTORY_SEPARATOR.$this->binaryName(); + + if (! $force && file_exists($binary)) { + $installedVersion = $this->getBinaryVersion($binary); + $chromeVersion = $this->getChromeVersion(); + + if ($installedVersion !== null && $chromeVersion !== null) { + $installedMajor = (int) explode('.', $installedVersion)[0]; + $chromeMajor = (int) explode('.', $chromeVersion)[0]; + + if ($installedMajor === $chromeMajor) { + return InstallationResult::skipped( + sprintf('ChromeDriver %s already installed and matches Chrome %s. Skipping.', $installedVersion, $chromeVersion), + ); + } + } + } + + return $this->download($driversDir); + } + public function platform(): string { $os = PHP_OS_FAMILY; @@ -220,6 +215,11 @@ public function resolveDriversDir(?string $dir = null): string return $dir; } + public function update(?string $driversDir = null): InstallationResult + { + return $this->install(force: true, driversDir: $driversDir); + } + private function extractBinary(string $zipPath, string $driversDir): void { if (! extension_loaded('zip')) { diff --git a/src/Driver/HttpClientInterface.php b/src/Driver/HttpClientInterface.php index 2655670..a3f2830 100644 --- a/src/Driver/HttpClientInterface.php +++ b/src/Driver/HttpClientInterface.php @@ -4,12 +4,14 @@ namespace Myerscode\Beacon\Driver; +use RuntimeException; + interface HttpClientInterface { /** * Fetch the contents of a URL. * - * @throws \RuntimeException If the request fails. + * @throws RuntimeException If the request fails. */ public function fetch(string $url): string; } diff --git a/tests/Crawler/CrawlConfigTest.php b/tests/Crawler/CrawlConfigTest.php index de81ccb..5a9502e 100644 --- a/tests/Crawler/CrawlConfigTest.php +++ b/tests/Crawler/CrawlConfigTest.php @@ -11,6 +11,87 @@ final class CrawlConfigTest extends TestCase { + public function testCalculateRequestDelayReturnsExactDelayWithNoJitter(): void + { + $crawlConfig = (new CrawlConfig())->requestDelay(500); + + $this->assertSame(500, $crawlConfig->calculateRequestDelay()); + } + + public function testCalculateRequestDelayReturnsZeroWhenNoDelay(): void + { + $crawlConfig = new CrawlConfig(); + + $this->assertSame(0, $crawlConfig->calculateRequestDelay()); + } + + public function testCalculateRequestDelayWithJitterStaysWithinBounds(): void + { + $crawlConfig = (new CrawlConfig()) + ->requestDelay(1000) + ->requestWithJitter(); + + for ($i = 0; $i < 100; $i++) { + $delay = $crawlConfig->calculateRequestDelay(); + $this->assertGreaterThanOrEqual(0, $delay); + $this->assertLessThanOrEqual(1000, $delay); + } + } + + public function testCalculateRetryDelayWithBackoffOfOneIsConstant(): void + { + $crawlConfig = (new CrawlConfig()) + ->retryDelay(200) + ->retryBackoff(1.0); + + $this->assertSame(200, $crawlConfig->calculateRetryDelay(1)); + $this->assertSame(200, $crawlConfig->calculateRetryDelay(2)); + $this->assertSame(200, $crawlConfig->calculateRetryDelay(3)); + } + + public function testCalculateRetryDelayWithCustomBackoff(): void + { + $crawlConfig = (new CrawlConfig()) + ->retryDelay(500) + ->retryBackoff(3.0); + + $this->assertSame(500, $crawlConfig->calculateRetryDelay(1)); + $this->assertSame(1500, $crawlConfig->calculateRetryDelay(2)); + $this->assertSame(4500, $crawlConfig->calculateRetryDelay(3)); + } + + public function testCalculateRetryDelayWithExponentialBackoff(): void + { + $crawlConfig = (new CrawlConfig()) + ->retryDelay(1000) + ->retryBackoff(2.0); + + $this->assertSame(1000, $crawlConfig->calculateRetryDelay(1)); + $this->assertSame(2000, $crawlConfig->calculateRetryDelay(2)); + $this->assertSame(4000, $crawlConfig->calculateRetryDelay(3)); + } + + public function testCalculateRetryDelayWithJitterStaysWithinBounds(): void + { + $crawlConfig = (new CrawlConfig()) + ->retryDelay(1000) + ->retryBackoff(2.0) + ->retryWithJitter(); + + for ($i = 0; $i < 100; $i++) { + $delay = $crawlConfig->calculateRetryDelay(1); + $this->assertGreaterThanOrEqual(0, $delay); + $this->assertLessThanOrEqual(1000, $delay); + + $delay = $crawlConfig->calculateRetryDelay(2); + $this->assertGreaterThanOrEqual(0, $delay); + $this->assertLessThanOrEqual(2000, $delay); + + $delay = $crawlConfig->calculateRetryDelay(3); + $this->assertGreaterThanOrEqual(0, $delay); + $this->assertLessThanOrEqual(4000, $delay); + } + } public function testDefaultValues(): void { $crawlConfig = new CrawlConfig(); @@ -139,20 +220,6 @@ public function testRequestDelay(): void $this->assertSame(500, $crawlConfig->getRequestDelay()); } - public function testTimeout(): void - { - $crawlConfig = new CrawlConfig()->timeout(60); - - $this->assertSame(60, $crawlConfig->getTimeout()); - } - - public function testRequestWithJitterDefaultsToFull(): void - { - $crawlConfig = (new CrawlConfig())->requestWithJitter(); - - $this->assertSame(JitterStrategy::FULL, $crawlConfig->getRequestJitter()); - } - public function testRequestWithJitterAcceptsStrategy(): void { $crawlConfig = (new CrawlConfig())->requestWithJitter(JitterStrategy::NONE); @@ -160,25 +227,11 @@ public function testRequestWithJitterAcceptsStrategy(): void $this->assertSame(JitterStrategy::NONE, $crawlConfig->getRequestJitter()); } - public function testRetryWithJitterDefaultsToFull(): void - { - $crawlConfig = (new CrawlConfig())->retryWithJitter(); - - $this->assertSame(JitterStrategy::FULL, $crawlConfig->getRetryJitter()); - } - - public function testRetryWithJitterAcceptsStrategy(): void - { - $crawlConfig = (new CrawlConfig())->retryWithJitter(JitterStrategy::NONE); - - $this->assertSame(JitterStrategy::NONE, $crawlConfig->getRetryJitter()); - } - - public function testRetryDelay(): void + public function testRequestWithJitterDefaultsToFull(): void { - $crawlConfig = (new CrawlConfig())->retryDelay(500); + $crawlConfig = (new CrawlConfig())->requestWithJitter(); - $this->assertSame(500, $crawlConfig->getRetryDelay()); + $this->assertSame(JitterStrategy::FULL, $crawlConfig->getRequestJitter()); } public function testRetryBackoff(): void @@ -188,85 +241,31 @@ public function testRetryBackoff(): void $this->assertSame(3.0, $crawlConfig->getRetryBackoff()); } - public function testCalculateRequestDelayReturnsZeroWhenNoDelay(): void - { - $crawlConfig = new CrawlConfig(); - - $this->assertSame(0, $crawlConfig->calculateRequestDelay()); - } - - public function testCalculateRequestDelayReturnsExactDelayWithNoJitter(): void - { - $crawlConfig = (new CrawlConfig())->requestDelay(500); - - $this->assertSame(500, $crawlConfig->calculateRequestDelay()); - } - - public function testCalculateRequestDelayWithJitterStaysWithinBounds(): void - { - $crawlConfig = (new CrawlConfig()) - ->requestDelay(1000) - ->requestWithJitter(); - - for ($i = 0; $i < 100; $i++) { - $delay = $crawlConfig->calculateRequestDelay(); - $this->assertGreaterThanOrEqual(0, $delay); - $this->assertLessThanOrEqual(1000, $delay); - } - } - - public function testCalculateRetryDelayWithExponentialBackoff(): void + public function testRetryDelay(): void { - $crawlConfig = (new CrawlConfig()) - ->retryDelay(1000) - ->retryBackoff(2.0); + $crawlConfig = (new CrawlConfig())->retryDelay(500); - $this->assertSame(1000, $crawlConfig->calculateRetryDelay(1)); - $this->assertSame(2000, $crawlConfig->calculateRetryDelay(2)); - $this->assertSame(4000, $crawlConfig->calculateRetryDelay(3)); + $this->assertSame(500, $crawlConfig->getRetryDelay()); } - public function testCalculateRetryDelayWithCustomBackoff(): void + public function testRetryWithJitterAcceptsStrategy(): void { - $crawlConfig = (new CrawlConfig()) - ->retryDelay(500) - ->retryBackoff(3.0); + $crawlConfig = (new CrawlConfig())->retryWithJitter(JitterStrategy::NONE); - $this->assertSame(500, $crawlConfig->calculateRetryDelay(1)); - $this->assertSame(1500, $crawlConfig->calculateRetryDelay(2)); - $this->assertSame(4500, $crawlConfig->calculateRetryDelay(3)); + $this->assertSame(JitterStrategy::NONE, $crawlConfig->getRetryJitter()); } - public function testCalculateRetryDelayWithBackoffOfOneIsConstant(): void + public function testRetryWithJitterDefaultsToFull(): void { - $crawlConfig = (new CrawlConfig()) - ->retryDelay(200) - ->retryBackoff(1.0); + $crawlConfig = (new CrawlConfig())->retryWithJitter(); - $this->assertSame(200, $crawlConfig->calculateRetryDelay(1)); - $this->assertSame(200, $crawlConfig->calculateRetryDelay(2)); - $this->assertSame(200, $crawlConfig->calculateRetryDelay(3)); + $this->assertSame(JitterStrategy::FULL, $crawlConfig->getRetryJitter()); } - public function testCalculateRetryDelayWithJitterStaysWithinBounds(): void + public function testTimeout(): void { - $crawlConfig = (new CrawlConfig()) - ->retryDelay(1000) - ->retryBackoff(2.0) - ->retryWithJitter(); - - for ($i = 0; $i < 100; $i++) { - $delay = $crawlConfig->calculateRetryDelay(1); - $this->assertGreaterThanOrEqual(0, $delay); - $this->assertLessThanOrEqual(1000, $delay); - - $delay = $crawlConfig->calculateRetryDelay(2); - $this->assertGreaterThanOrEqual(0, $delay); - $this->assertLessThanOrEqual(2000, $delay); + $crawlConfig = new CrawlConfig()->timeout(60); - $delay = $crawlConfig->calculateRetryDelay(3); - $this->assertGreaterThanOrEqual(0, $delay); - $this->assertLessThanOrEqual(4000, $delay); - } + $this->assertSame(60, $crawlConfig->getTimeout()); } } diff --git a/tests/Crawler/SpiderTest.php b/tests/Crawler/SpiderTest.php index 01db955..8fbc519 100644 --- a/tests/Crawler/SpiderTest.php +++ b/tests/Crawler/SpiderTest.php @@ -13,28 +13,45 @@ final class SpiderTest extends TestCase { // ========================================================================= - // Start page behaviour + // Deduplication // ========================================================================= - public function testCrawlRecordsStartPage(): void + public function testCrawlDeduplicatesExternalLinks(): void { - $results = $this->crawl('https://example.com', 'No links'); + $results = $this->crawl( + 'https://example.com', + 'Link 1Link 2', + ); - $this->assertCount(1, $results); - $this->assertTrue($results->has('https://example.com')); + $this->assertCount(2, $results); // start + 1 deduplicated external + } - $start = $results->get('https://example.com'); - $this->assertTrue($start->internal); - $this->assertSame(200, $start->statusCode); - $this->assertSame(0, $start->depth); + public function testCrawlDeduplicatesInternalSeedLinks(): void + { + $crawled = []; + $config = (new CrawlConfig())->maxDepth(0)->onCrawled(function (string $url) use (&$crawled): void { + $crawled[] = $url; + }); + + $results = $this->crawl( + 'https://example.com', + 'AboutAbout Again', + $config, + ); + + $this->assertCount(1, $results); + $this->assertSame(['https://example.com'], $crawled); } - public function testCrawlNormalizesTrailingSlashOnStartUrl(): void + public function testCrawlExternalLinkDepthIsOne(): void { - $results = $this->crawl('https://example.com/', ''); + $results = $this->crawl( + 'https://example.com', + 'External', + ); - $this->assertTrue($results->has('https://example.com')); - $this->assertFalse($results->has('https://example.com/')); + $external = $results->get('https://other.com'); + $this->assertSame(1, $external->depth); } // ========================================================================= @@ -51,14 +68,15 @@ public function testCrawlExtractsAbsoluteLinks(): void $this->assertTrue($results->has('https://example.com/about')); } - public function testCrawlExtractsRootRelativeLinks(): void + public function testCrawlExtractsLinksWithQueryParameters(): void { $results = $this->crawl( 'https://example.com', - 'Contact', + 'Search', ); - $this->assertTrue($results->has('https://example.com/contact')); + $result = $results->get('https://example.com/search?q=test&page=2'); + $this->assertNotNull($result); } public function testCrawlExtractsPathRelativeLinks(): void @@ -71,25 +89,14 @@ public function testCrawlExtractsPathRelativeLinks(): void $this->assertTrue($results->has('https://example.com/blog/post')); } - public function testCrawlResolvesProtocolRelativeLinks(): void - { - $results = $this->crawl( - 'https://example.com', - 'CDN', - ); - - $this->assertTrue($results->has('https://cdn.example.com/file')); - } - - public function testCrawlExtractsLinksWithQueryParameters(): void + public function testCrawlExtractsRootRelativeLinks(): void { $results = $this->crawl( 'https://example.com', - 'Search', + 'Contact', ); - $result = $results->get('https://example.com/search?q=test&page=2'); - $this->assertNotNull($result); + $this->assertTrue($results->has('https://example.com/contact')); } public function testCrawlExtractsSingleQuotedHrefs(): void @@ -102,70 +109,91 @@ public function testCrawlExtractsSingleQuotedHrefs(): void $this->assertTrue($results->has('https://example.com/about')); } - public function testCrawlStripsFragmentsFromLinks(): void + public function testCrawlFiresOnCrawledForExternalLinks(): void { - $results = $this->crawl( + $crawled = []; + $config = (new CrawlConfig())->onCrawled(function (string $url) use (&$crawled): void { + $crawled[] = $url; + }); + + $this->crawl( 'https://example.com', - 'Page', + 'External', + $config, ); - $this->assertTrue($results->has('https://example.com/page')); - $this->assertFalse($results->has('https://example.com/page#section')); + $this->assertContains('https://other.com', $crawled); } - public function testCrawlTrimsWhitespaceFromHrefs(): void + // ========================================================================= + // Callbacks + // ========================================================================= + + public function testCrawlFiresOnCrawledForStartPage(): void { - $results = $this->crawl( - 'https://example.com', - 'About', - ); + $crawled = []; + $config = (new CrawlConfig())->onCrawled(function (string $url) use (&$crawled): void { + $crawled[] = $url; + }); - $this->assertTrue($results->has('https://example.com/about')); + $this->crawl('https://example.com', '', $config); + + $this->assertContains('https://example.com', $crawled); } // ========================================================================= - // Skipped link types + // Multi-page crawl (mocked client) // ========================================================================= - public function testCrawlSkipsFragmentOnlyLinks(): void + public function testCrawlFollowsInternalLinksAndRecordsLinkedFrom(): void { - $results = $this->crawl( - 'https://example.com', - 'Jump', + $client = $this->createStub(ClientInterface::class); + + $client->method('getPageSource')->willReturn( + 'Deep Link', ); + $client->method('getStatusCode')->willReturn(200); - $this->assertCount(1, $results); // Only the start page - } + $factory = $this->createStub(ClientFactory::class); + $factory->method('create')->willReturn($client); - public function testCrawlSkipsJavascriptLinks(): void - { - $results = $this->crawl( + $config = (new CrawlConfig())->maxDepth(2); + $spider = new Spider($config, $factory); + + $results = $spider->crawl( 'https://example.com', - 'JS', + 'About', ); - $this->assertCount(1, $results); + // Start page + /about (followed) + /from-about (discovered from /about) + $this->assertTrue($results->has('https://example.com')); + $this->assertTrue($results->has('https://example.com/about')); + + $about = $results->get('https://example.com/about'); + $this->assertSame(200, $about->statusCode); + $this->assertContains('https://example.com', $about->linkedFrom); } - public function testCrawlSkipsMailtoLinks(): void + public function testCrawlMixedInternalAndExternalWithMaxDepthZero(): void { + $config = (new CrawlConfig())->maxDepth(0); $results = $this->crawl( 'https://example.com', - 'EmailReal', + 'AboutOther', + $config, ); - $this->assertFalse($results->has('mailto:test@example.com')); - $this->assertTrue($results->has('https://example.com/real')); + // Internal links not followed at depth 0, but externals still recorded + $this->assertFalse($results->has('https://example.com/about')); + $this->assertTrue($results->has('https://other.com')); } - public function testCrawlSkipsTelLinks(): void + public function testCrawlNormalizesTrailingSlashOnStartUrl(): void { - $results = $this->crawl( - 'https://example.com', - 'Call', - ); + $results = $this->crawl('https://example.com/', ''); - $this->assertCount(1, $results); + $this->assertTrue($results->has('https://example.com')); + $this->assertFalse($results->has('https://example.com/')); } // ========================================================================= @@ -186,29 +214,6 @@ public function testCrawlRecordsExternalLinksWithoutFollowing(): void $this->assertContains('https://example.com', $external->linkedFrom); } - public function testCrawlTreatsSubdomainAsExternal(): void - { - $results = $this->crawl( - 'https://example.com', - 'Sub', - ); - - $sub = $results->get('https://sub.example.com/page'); - $this->assertNotNull($sub); - $this->assertFalse($sub->internal); - } - - public function testCrawlExternalLinkDepthIsOne(): void - { - $results = $this->crawl( - 'https://example.com', - 'External', - ); - - $external = $results->get('https://other.com'); - $this->assertSame(1, $external->depth); - } - public function testCrawlRecordsMultipleExternalLinks(): void { $results = $this->crawl( @@ -221,50 +226,48 @@ public function testCrawlRecordsMultipleExternalLinks(): void $this->assertTrue($results->has('https://c.com')); $this->assertCount(4, $results); // start + 3 external } + // ========================================================================= + // Start page behaviour + // ========================================================================= - public function testCrawlMixedInternalAndExternalWithMaxDepthZero(): void + public function testCrawlRecordsStartPage(): void { - $config = (new CrawlConfig())->maxDepth(0); - $results = $this->crawl( - 'https://example.com', - 'AboutOther', - $config, - ); + $results = $this->crawl('https://example.com', 'No links'); - // Internal links not followed at depth 0, but externals still recorded - $this->assertFalse($results->has('https://example.com/about')); - $this->assertTrue($results->has('https://other.com')); - } + $this->assertCount(1, $results); + $this->assertTrue($results->has('https://example.com')); - // ========================================================================= - // Deduplication - // ========================================================================= + $start = $results->get('https://example.com'); + $this->assertTrue($start->internal); + $this->assertSame(200, $start->statusCode); + $this->assertSame(0, $start->depth); + } - public function testCrawlDeduplicatesExternalLinks(): void + public function testCrawlResolvesProtocolRelativeLinks(): void { $results = $this->crawl( 'https://example.com', - 'Link 1Link 2', + 'CDN', ); - $this->assertCount(2, $results); // start + 1 deduplicated external + $this->assertTrue($results->has('https://cdn.example.com/file')); } - public function testCrawlDeduplicatesInternalSeedLinks(): void - { - $crawled = []; - $config = (new CrawlConfig())->maxDepth(0)->onCrawled(function (string $url) use (&$crawled): void { - $crawled[] = $url; - }); + // ========================================================================= + // Configuration — exclude patterns + // ========================================================================= + public function testCrawlRespectsExcludePatterns(): void + { + $config = (new CrawlConfig())->exclude(['/admin']); $results = $this->crawl( 'https://example.com', - 'AboutAbout Again', + 'AdminExternal', $config, ); - $this->assertCount(1, $results); - $this->assertSame(['https://example.com'], $crawled); + $this->assertFalse($results->has('https://example.com/admin/dashboard')); + $this->assertTrue($results->has('https://other.com')); } // ========================================================================= @@ -285,101 +288,97 @@ public function testCrawlRespectsMaxDepthZero(): void } // ========================================================================= - // Configuration — exclude patterns + // Configuration — shouldCrawl closure // ========================================================================= - public function testCrawlRespectsExcludePatterns(): void + public function testCrawlRespectsShouldCrawlClosure(): void { - $config = (new CrawlConfig())->exclude(['/admin']); + $config = (new CrawlConfig())->shouldCrawl(fn (string $url) => !str_contains($url, 'private')); $results = $this->crawl( 'https://example.com', - 'AdminExternal', + 'Private', $config, ); - $this->assertFalse($results->has('https://example.com/admin/dashboard')); - $this->assertTrue($results->has('https://other.com')); + $this->assertFalse($results->has('https://example.com/private/page')); } // ========================================================================= - // Configuration — shouldCrawl closure + // Skipped link types // ========================================================================= - public function testCrawlRespectsShouldCrawlClosure(): void + public function testCrawlSkipsFragmentOnlyLinks(): void { - $config = (new CrawlConfig())->shouldCrawl(fn (string $url) => !str_contains($url, 'private')); $results = $this->crawl( 'https://example.com', - 'Private', - $config, + 'Jump', ); - $this->assertFalse($results->has('https://example.com/private/page')); + $this->assertCount(1, $results); // Only the start page } - // ========================================================================= - // Callbacks - // ========================================================================= - - public function testCrawlFiresOnCrawledForStartPage(): void + public function testCrawlSkipsJavascriptLinks(): void { - $crawled = []; - $config = (new CrawlConfig())->onCrawled(function (string $url) use (&$crawled): void { - $crawled[] = $url; - }); - - $this->crawl('https://example.com', '', $config); + $results = $this->crawl( + 'https://example.com', + 'JS', + ); - $this->assertContains('https://example.com', $crawled); + $this->assertCount(1, $results); } - public function testCrawlFiresOnCrawledForExternalLinks(): void + public function testCrawlSkipsMailtoLinks(): void { - $crawled = []; - $config = (new CrawlConfig())->onCrawled(function (string $url) use (&$crawled): void { - $crawled[] = $url; - }); - - $this->crawl( + $results = $this->crawl( 'https://example.com', - 'External', - $config, + 'EmailReal', ); - $this->assertContains('https://other.com', $crawled); + $this->assertFalse($results->has('mailto:test@example.com')); + $this->assertTrue($results->has('https://example.com/real')); } - // ========================================================================= - // Multi-page crawl (mocked client) - // ========================================================================= - - public function testCrawlFollowsInternalLinksAndRecordsLinkedFrom(): void + public function testCrawlSkipsTelLinks(): void { - $client = $this->createStub(ClientInterface::class); + $results = $this->crawl( + 'https://example.com', + 'Call', + ); - $client->method('getPageSource')->willReturn( - 'Deep Link', + $this->assertCount(1, $results); + } + + public function testCrawlStripsFragmentsFromLinks(): void + { + $results = $this->crawl( + 'https://example.com', + 'Page', ); - $client->method('getStatusCode')->willReturn(200); - $factory = $this->createStub(ClientFactory::class); - $factory->method('create')->willReturn($client); + $this->assertTrue($results->has('https://example.com/page')); + $this->assertFalse($results->has('https://example.com/page#section')); + } - $config = (new CrawlConfig())->maxDepth(2); - $spider = new Spider($config, $factory); + public function testCrawlTreatsSubdomainAsExternal(): void + { + $results = $this->crawl( + 'https://example.com', + 'Sub', + ); - $results = $spider->crawl( + $sub = $results->get('https://sub.example.com/page'); + $this->assertNotNull($sub); + $this->assertFalse($sub->internal); + } + + public function testCrawlTrimsWhitespaceFromHrefs(): void + { + $results = $this->crawl( 'https://example.com', - 'About', + 'About', ); - // Start page + /about (followed) + /from-about (discovered from /about) - $this->assertTrue($results->has('https://example.com')); $this->assertTrue($results->has('https://example.com/about')); - - $about = $results->get('https://example.com/about'); - $this->assertSame(200, $about->statusCode); - $this->assertContains('https://example.com', $about->linkedFrom); } // ========================================================================= diff --git a/tests/Driver/ChromeDriverInstallerDownloadTest.php b/tests/Driver/ChromeDriverInstallerDownloadTest.php index a46af85..2076d51 100644 --- a/tests/Driver/ChromeDriverInstallerDownloadTest.php +++ b/tests/Driver/ChromeDriverInstallerDownloadTest.php @@ -9,6 +9,7 @@ use Myerscode\Beacon\Support\InstallationResult; use PHPUnit\Framework\TestCase; use RuntimeException; +use ZipArchive; final class ChromeDriverInstallerDownloadTest extends TestCase { @@ -25,27 +26,7 @@ protected function tearDown(): void $this->removeDirectory($this->tmpDir); } - // ========================================================================= - // download() — chrome not detected - // ========================================================================= - - public function testDownloadSkipsWhenChromeNotDetected(): void - { - $httpClient = $this->createStub(HttpClientInterface::class); - - $installer = $this->createInstallerWithChromeVersion(null, $httpClient); - - $result = $installer->download($this->tmpDir); - - $this->assertSame(InstallationResult::STATUS_SKIPPED, $result->status); - $this->assertStringContainsString('not detected', $result->summary); - } - - // ========================================================================= - // download() — successful install - // ========================================================================= - - public function testDownloadInstallsBinarySuccessfully(): void + public function testDownloadIncludesDetectedVersionInMessages(): void { $platform = (new ChromeDriverInstaller())->platform(); $binaryName = (new ChromeDriverInstaller())->binaryName(); @@ -68,12 +49,15 @@ function (string $url) use ($versionsJson, $zipContent): string { $result = $installer->download($this->tmpDir); - $this->assertSame(InstallationResult::STATUS_SUCCESS, $result->status); - $this->assertStringContainsString('installed', $result->summary); - $this->assertFileExists($this->tmpDir . DIRECTORY_SEPARATOR . $binaryName); + $this->assertNotEmpty($result->messages); + $this->assertStringContainsString('120.0.6099.109', $result->messages[0]); } - public function testDownloadIncludesDetectedVersionInMessages(): void + // ========================================================================= + // download() — successful install + // ========================================================================= + + public function testDownloadInstallsBinarySuccessfully(): void { $platform = (new ChromeDriverInstaller())->platform(); $binaryName = (new ChromeDriverInstaller())->binaryName(); @@ -96,8 +80,25 @@ function (string $url) use ($versionsJson, $zipContent): string { $result = $installer->download($this->tmpDir); - $this->assertNotEmpty($result->messages); - $this->assertStringContainsString('120.0.6099.109', $result->messages[0]); + $this->assertSame(InstallationResult::STATUS_SUCCESS, $result->status); + $this->assertStringContainsString('installed', $result->summary); + $this->assertFileExists($this->tmpDir . DIRECTORY_SEPARATOR . $binaryName); + } + + // ========================================================================= + // download() — chrome not detected + // ========================================================================= + + public function testDownloadSkipsWhenChromeNotDetected(): void + { + $httpClient = $this->createStub(HttpClientInterface::class); + + $installer = $this->createInstallerWithChromeVersion(null, $httpClient); + + $result = $installer->download($this->tmpDir); + + $this->assertSame(InstallationResult::STATUS_SKIPPED, $result->status); + $this->assertStringContainsString('not detected', $result->summary); } // ========================================================================= @@ -147,38 +148,6 @@ public function testDownloadThrowsWhenHttpClientFails(): void $installer->download($this->tmpDir); } - // ========================================================================= - // download() — version resolution - // ========================================================================= - - public function testDownloadUsesClosestVersionWhenExactMatchMissing(): void - { - $platform = (new ChromeDriverInstaller())->platform(); - $binaryName = (new ChromeDriverInstaller())->binaryName(); - - // Versions JSON has 120.0.6099.100 but not 120.0.6099.109 - $versionsJson = $this->buildVersionsJson('120.0.6099.100', $platform); - $zipContent = $this->buildZipWithBinary($binaryName); - - $httpClient = $this->createStub(HttpClientInterface::class); - $httpClient->method('fetch')->willReturnCallback( - function (string $url) use ($versionsJson, $zipContent): string { - if (str_contains($url, 'known-good-versions')) { - return $versionsJson; - } - - return $zipContent; - }, - ); - - $installer = $this->createInstallerWithChromeVersion('120.0.6099.109', $httpClient); - - $result = $installer->download($this->tmpDir); - - $this->assertSame(InstallationResult::STATUS_SUCCESS, $result->status); - $this->assertStringContainsString('closest', $result->messages[2] ?? ''); - } - public function testDownloadThrowsWhenNoVersionForPlatform(): void { $this->expectException(RuntimeException::class); @@ -210,19 +179,50 @@ public function testDownloadThrowsWhenVersionsJsonIsInvalid(): void } // ========================================================================= - // Helpers + // download() — version resolution // ========================================================================= - private function createInstallerWithChromeVersion(?string $version, HttpClientInterface $httpClient): ChromeDriverInstaller + public function testDownloadUsesClosestVersionWhenExactMatchMissing(): void { - $installer = $this->getMockBuilder(ChromeDriverInstaller::class) - ->setConstructorArgs([$httpClient]) - ->onlyMethods(['getChromeVersion']) - ->getMock(); + $platform = (new ChromeDriverInstaller())->platform(); + $binaryName = (new ChromeDriverInstaller())->binaryName(); - $installer->method('getChromeVersion')->willReturn($version); + // Versions JSON has 120.0.6099.100 but not 120.0.6099.109 + $versionsJson = $this->buildVersionsJson('120.0.6099.100', $platform); + $zipContent = $this->buildZipWithBinary($binaryName); - return $installer; + $httpClient = $this->createStub(HttpClientInterface::class); + $httpClient->method('fetch')->willReturnCallback( + function (string $url) use ($versionsJson, $zipContent): string { + if (str_contains($url, 'known-good-versions')) { + return $versionsJson; + } + + return $zipContent; + }, + ); + + $installer = $this->createInstallerWithChromeVersion('120.0.6099.109', $httpClient); + + $result = $installer->download($this->tmpDir); + + $this->assertSame(InstallationResult::STATUS_SUCCESS, $result->status); + $this->assertStringContainsString('closest', $result->messages[2] ?? ''); + } + + private function buildEmptyZip(): string + { + $tmpZip = sys_get_temp_dir() . '/beacon_test_zip_' . uniqid() . '.zip'; + + $zip = new ZipArchive(); + $zip->open($tmpZip, ZipArchive::CREATE); + $zip->addFromString('readme.txt', 'no binary here'); + $zip->close(); + + $content = file_get_contents($tmpZip); + unlink($tmpZip); + + return $content; } private function buildVersionsJson(string $version, string $platform): string @@ -248,8 +248,8 @@ private function buildZipWithBinary(string $binaryName): string { $tmpZip = sys_get_temp_dir() . '/beacon_test_zip_' . uniqid() . '.zip'; - $zip = new \ZipArchive(); - $zip->open($tmpZip, \ZipArchive::CREATE); + $zip = new ZipArchive(); + $zip->open($tmpZip, ZipArchive::CREATE); $zip->addFromString('chromedriver-linux64/' . $binaryName, "#!/bin/sh\necho 'ChromeDriver 120.0.6099.109'\n"); $zip->close(); @@ -259,19 +259,20 @@ private function buildZipWithBinary(string $binaryName): string return $content; } - private function buildEmptyZip(): string - { - $tmpZip = sys_get_temp_dir() . '/beacon_test_zip_' . uniqid() . '.zip'; + // ========================================================================= + // Helpers + // ========================================================================= - $zip = new \ZipArchive(); - $zip->open($tmpZip, \ZipArchive::CREATE); - $zip->addFromString('readme.txt', 'no binary here'); - $zip->close(); + private function createInstallerWithChromeVersion(?string $version, HttpClientInterface $httpClient): ChromeDriverInstaller + { + $installer = $this->getMockBuilder(ChromeDriverInstaller::class) + ->setConstructorArgs([$httpClient]) + ->onlyMethods(['getChromeVersion']) + ->getMock(); - $content = file_get_contents($tmpZip); - unlink($tmpZip); + $installer->method('getChromeVersion')->willReturn($version); - return $content; + return $installer; } private function removeDirectory(string $dir): void diff --git a/tests/PageLighthouseTest.php b/tests/PageLighthouseTest.php index fcf84cf..2cd81ea 100644 --- a/tests/PageLighthouseTest.php +++ b/tests/PageLighthouseTest.php @@ -13,34 +13,41 @@ final class PageLighthouseTest extends TestCase { - public function testLighthouseDelegatesToRunner(): void + public function testAuditDelegatesToLighthouseResult(): void + { + $page = $this->createPageWithMockedLighthouse(); + $audits = $page->audit(); + + $this->assertCount(2, $audits); + } + + public function testLighthouseCachesResult(): void { $runner = $this->createMock(LighthouseRunner::class); $runner->expects($this->once()) ->method('run') - ->with('https://example.com') ->willReturn(new LighthouseResult($this->sampleData())); $page = new Page($this->createStub(ClientInterface::class), 'https://example.com'); $page->withLighthouseRunner($runner); - $scores = $page->lighthouse(); - - $this->assertArrayHasKey('performance', $scores); + $page->lighthouse(); + $page->lighthouse(); } - - public function testLighthouseCachesResult(): void + public function testLighthouseDelegatesToRunner(): void { $runner = $this->createMock(LighthouseRunner::class); $runner->expects($this->once()) ->method('run') + ->with('https://example.com') ->willReturn(new LighthouseResult($this->sampleData())); $page = new Page($this->createStub(ClientInterface::class), 'https://example.com'); $page->withLighthouseRunner($runner); - $page->lighthouse(); - $page->lighthouse(); + $scores = $page->lighthouse(); + + $this->assertArrayHasKey('performance', $scores); } public function testLighthousePassesCategoryFilters(): void @@ -54,14 +61,6 @@ public function testLighthousePassesCategoryFilters(): void $this->assertCount(1, $single); } - public function testAuditDelegatesToLighthouseResult(): void - { - $page = $this->createPageWithMockedLighthouse(); - $audits = $page->audit(); - - $this->assertCount(2, $audits); - } - public function testLighthouseResultReturnsFullObject(): void { $page = $this->createPageWithMockedLighthouse(); From 470dc344c43c7d70a396fa4cb51c304b95879479 Mon Sep 17 00:00:00 2001 From: Fred Myerscough Date: Sat, 31 Oct 2026 00:29:51 +0000 Subject: [PATCH 4/4] fix: specify Fiber generic type params for phpstan --- src/Browser.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Browser.php b/src/Browser.php index 69e9a29..32dbc12 100644 --- a/src/Browser.php +++ b/src/Browser.php @@ -186,7 +186,7 @@ public function visitAll(array $urls, int $concurrency = 5): array $slotCount = min($concurrency, count($urls)); - /** @var array $fibers keyed by slot */ + /** @var array> $fibers keyed by slot */ $fibers = []; /** @var array $fiberIndex maps slot to original URL index */