diff --git a/.gitignore b/.gitignore index 4733d042..ff95abad 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ coverage.xml # Playwright node_modules/ /tests/Browser/Screenshots +/tests/Browser/Source # MacOS .DS_Store diff --git a/src/Configuration.php b/src/Configuration.php index bf3c458f..4f97e1b4 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -124,4 +124,14 @@ public function diff(): self return $this; } + + /** + * Saves the page source when a browser assertion fails. + */ + public function source(): self + { + Playwright::setShouldSaveSourceOnFailedAssertions(); + + return $this; + } } diff --git a/src/Exceptions/BrowserExpectationFailedException.php b/src/Exceptions/BrowserExpectationFailedException.php index 8fff0be3..3a7b0235 100644 --- a/src/Exceptions/BrowserExpectationFailedException.php +++ b/src/Exceptions/BrowserExpectationFailedException.php @@ -7,6 +7,7 @@ use Pest\Browser\Playwright\Page; use Pest\Browser\Playwright\Playwright; use Pest\Browser\ServerManager; +use Pest\Browser\Support\Source; use PHPUnit\Framework\ExpectationFailedException; use Throwable; @@ -28,6 +29,16 @@ public static function from(Page $page, ExpectationFailedException $e): Expectat if ($filename !== null) { $message .= " A screenshot of the page has been saved to [Tests/Browser/Screenshots/$filename]."; } + + if (Playwright::shouldSaveSourceOnFailedAssertions()) { + try { + $filename = Source::save($page->content()); + + $message .= " The source of the page has been saved to [Tests/Browser/Source/$filename]."; + } catch (Throwable) { + // saving the source must never mask the original failure... + } + } } $consoleLogs = $page->consoleLogs(); diff --git a/src/Filters/UsesBrowserTestCaseMethodFilter.php b/src/Filters/UsesBrowserTestCaseMethodFilter.php index a38469be..a87e49f3 100644 --- a/src/Filters/UsesBrowserTestCaseMethodFilter.php +++ b/src/Filters/UsesBrowserTestCaseMethodFilter.php @@ -9,6 +9,7 @@ use Pest\Browser\ServerManager; use Pest\Browser\Support\BrowserTestIdentifier; use Pest\Browser\Support\Screenshot; +use Pest\Browser\Support\Source; use Pest\Contracts\TestCaseMethodFilter; use Pest\Factories\TestCaseMethodFactory; use Pest\Plugins\Only; @@ -59,6 +60,7 @@ public function accept(TestCaseMethodFactory $factory): bool ServerManager::instance()->playwright()->start(); Screenshot::cleanup(); + Source::cleanup(); } return true; diff --git a/src/Playwright/Playwright.php b/src/Playwright/Playwright.php index c0cffae1..af319dfd 100644 --- a/src/Playwright/Playwright.php +++ b/src/Playwright/Playwright.php @@ -34,6 +34,11 @@ final class Playwright */ private static bool $shouldDiffOnScreenshotAssertions = false; + /** + * Whether to save the page source on failed assertions. + */ + private static bool $shouldSaveSourceOnFailedAssertions = false; + /** * The default browser type. */ @@ -187,6 +192,22 @@ public static function shouldDebugAssertions(): bool return self::$shouldDebugAssertions; } + /** + * Set whether to save the page source on failed assertions. + */ + public static function setShouldSaveSourceOnFailedAssertions(): void + { + self::$shouldSaveSourceOnFailedAssertions = true; + } + + /** + * Whether to save the page source on failed assertions. + */ + public static function shouldSaveSourceOnFailedAssertions(): bool + { + return self::$shouldSaveSourceOnFailedAssertions; + } + /** * Reset playwright state, reset browser types, without closing them. */ diff --git a/src/Support/Source.php b/src/Support/Source.php new file mode 100644 index 00000000..9a7abd4d --- /dev/null +++ b/src/Support/Source.php @@ -0,0 +1,78 @@ +rootPath + .'/tests/Browser/Source'; + } + + /** + * Return the full path for a source file. + */ + public static function path(string $filename): string + { + $filename = self::dir().'/'.mb_ltrim($filename, '/'); + + // check if there is extension, if not, add .html + if (pathinfo($filename, PATHINFO_EXTENSION) === '') { + $filename .= '.html'; + } + + return $filename; + } + + /** + * Save the page source to the filesystem. + */ + public static function save(string $content, ?string $filename = null): string + { + if ($filename === null) { + // @phpstan-ignore-next-line + $filename = str_replace('__pest_evaluable_', '', test()->name()); + } + + if (is_dir(self::dir()) === false) { + @mkdir(self::dir(), 0755, true); + } + + file_put_contents(self::path($filename), $content); + + return $filename; + } + + /** + * Clean up the sources directory. + * + * @codeCoverageIgnore + */ + public static function cleanup(): void + { + if (is_dir(self::dir()) === false) { + return; + } + + $files = glob(self::dir().'/*'); + + if (is_array($files)) { + foreach ($files as $file) { + @unlink($file); + } + } + + @rmdir(self::dir()); + } +} diff --git a/tests/Browser/Webpage/SourceOnFailureTest.php b/tests/Browser/Webpage/SourceOnFailureTest.php new file mode 100644 index 00000000..185553cc --- /dev/null +++ b/tests/Browser/Webpage/SourceOnFailureTest.php @@ -0,0 +1,53 @@ +setValue(null, false); +}); + +it('may not save the page source when an assertion fails', function (): void { + Route::get('/', fn (): string => '
Hello World
'); + + $page = visit('/'); + + try { + $page->assertSee('Goodbye World'); + } catch (ExpectationFailedException $exception) { + $filename = str_replace('__pest_evaluable_', '', test()->name()); + + expect($exception->getMessage())->not->toContain('The source of the page has been saved to') + ->and(file_exists(Source::path($filename)))->toBeFalse(); + + return; + } + + $this->fail('The assertion did not fail as expected.'); +}); + +it('may save the page source when an assertion fails', function (): void { + Playwright::setShouldSaveSourceOnFailedAssertions(); + + Route::get('/', fn (): string => '
Hello World
'); + + $page = visit('/'); + + try { + $page->assertSee('Goodbye World'); + } catch (ExpectationFailedException $exception) { + $filename = str_replace('__pest_evaluable_', '', test()->name()); + + expect($exception->getMessage())->toContain("The source of the page has been saved to [Tests/Browser/Source/$filename].") + ->and(file_exists(Source::path($filename)))->toBeTrue() + ->and((string) file_get_contents(Source::path($filename)))->toContain('
Hello World
'); + + return; + } + + $this->fail('The assertion did not fail as expected.'); +}); diff --git a/tests/Unit/Support/SourceTest.php b/tests/Unit/Support/SourceTest.php new file mode 100644 index 00000000..42de02f3 --- /dev/null +++ b/tests/Unit/Support/SourceTest.php @@ -0,0 +1,49 @@ +', 'test-source.html'); + + expect(file_exists(Source::path('test-source.html'))) + ->toBeTrue(); +}); + +it('saves sources with .html extension when no extension is provided', function (): void { + Source::save('', 'test-source'); + + expect(file_exists(Source::path('test-source.html'))) + ->toBeTrue(); +}); + +it('saves sources with .html extension when no extension is provided and the filename starts with a slash', function (): void { + Source::save('', '/test-source'); + + expect(file_exists(Source::path('test-source.html'))) + ->toBeTrue(); +}); + +it('saves the given content as-is', function (): void { + Source::save('Hello World', 'test-source-content'); + + expect(file_get_contents(Source::path('test-source-content.html'))) + ->toBe('Hello World'); +}); + +it('saves sources using the test name when no filename is given', function (): void { + $filename = Source::save(''); + + expect($filename)->not->toContain('__pest_evaluable_') + ->and(file_exists(Source::path($filename)))->toBeTrue(); +}); + +it('cleans up the sources directory', function (): void { + Source::save('', 'test-source-cleanup'); + + Source::cleanup(); + + expect(file_exists(Source::path('test-source-cleanup.html'))) + ->toBeFalse(); +});