diff --git a/.gitignore b/.gitignore index 4733d042..ab06b104 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ coverage.xml # Playwright node_modules/ /tests/Browser/Screenshots +/tests/Browser/Videos # MacOS .DS_Store diff --git a/src/Api/PendingAwaitablePage.php b/src/Api/PendingAwaitablePage.php index 83ad7b77..10b5edda 100644 --- a/src/Api/PendingAwaitablePage.php +++ b/src/Api/PendingAwaitablePage.php @@ -170,22 +170,45 @@ private function createAwaitablePage(): AwaitableWebpage */ private function buildAwaitablePage(array $options): AwaitableWebpage { - $browser = Playwright::browser($this->browserType)->launch(); + if (Playwright::shouldRecordVideoOnFailure()) { + // @phpstan-ignore-next-line + $testName = str_replace('__pest_evaluable_', '', test()->name()); + // @phpstan-ignore-next-line + $sanitized = (string) preg_replace('/[^a-zA-Z0-9_-]/', '_', $testName); + } - $context = $browser->newContext([ + $contextOptions = [ 'locale' => 'en-US', 'timezoneId' => 'UTC', 'colorScheme' => Playwright::defaultColorScheme()->value, ...$this->device->context(), ...$options, - ]); + ]; + + if (Playwright::shouldRecordVideoOnFailure()) { + // Empty array — Playwright uses its own temp storage; we retrieve via saveAs + $contextOptions['recordVideo'] = []; + } + + $browser = Playwright::browser($this->browserType)->launch(); + + $context = $browser->newContext($contextOptions); $context->addInitScript(InitScript::get()); $url = ComputeUrl::from($this->url); + // Strip context-only options before passing to goto + $gotoOptions = array_diff_key($options, array_flip(['host'])); + + $page = $context->newPage()->goto($url, $gotoOptions); + + if (Playwright::shouldRecordVideoOnFailure()) { + Playwright::registerVideoRecording($page, $sanitized); // @phpstan-ignore-line + } + return new AwaitableWebpage( - $context->newPage()->goto($url, $options), + $page, $url, ); } diff --git a/src/Configuration.php b/src/Configuration.php index bf3c458f..a99e4e95 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -124,4 +124,14 @@ public function diff(): self return $this; } + + /** + * Records a video of the browser session and saves it on test failure. + */ + public function recordVideoOnFailure(): self + { + Playwright::setRecordVideoOnFailure(); + + return $this; + } } diff --git a/src/Exceptions/BrowserExpectationFailedException.php b/src/Exceptions/BrowserExpectationFailedException.php index 8fff0be3..eb3de960 100644 --- a/src/Exceptions/BrowserExpectationFailedException.php +++ b/src/Exceptions/BrowserExpectationFailedException.php @@ -30,6 +30,12 @@ public static function from(Page $page, ExpectationFailedException $e): Expectat } } + $pendingVideoDestName = Playwright::pendingVideoDestName(); + + if ($pendingVideoDestName !== null) { + $message .= " A video recording of the browser session has been saved to [Tests/Browser/Videos/$pendingVideoDestName.webm]."; + } + $consoleLogs = $page->consoleLogs(); if (count($consoleLogs) > 0) { diff --git a/src/Playwright/Context.php b/src/Playwright/Context.php index 447d8582..b83b55fd 100644 --- a/src/Playwright/Context.php +++ b/src/Playwright/Context.php @@ -45,8 +45,9 @@ public function newPage(): Page $frameGuid = ''; $pageGuid = ''; + $artifactGuid = null; - /** @var array{method: string|null, params: array{type: string|null, guid: string, initializer: array{url: string}}, result: array{page: array{guid: string|null}}} $message */ + /** @var array{method: string|null, params: array{type: string|null, guid: string, initializer: array{url: string, mainFrame?: array{guid: string}, video?: array{guid: string}}}, result: array{page: array{guid: string|null}}} $message */ foreach ($response as $message) { if (isset($message['method']) && $message['method'] === '__create__' && (isset($message['params']['type']) && $message['params']['type'] === 'Frame')) { $frameGuid = $message['params']['guid']; @@ -55,9 +56,18 @@ public function newPage(): Page if (isset($message['result']['page']['guid'])) { $pageGuid = $message['result']['page']['guid']; } + + // The video recording artifact GUID is sent in the Page __create__ initializer + if ( + isset($message['method'], $message['params']['type'], $message['params']['initializer']['video']['guid']) + && $message['method'] === '__create__' + && $message['params']['type'] === 'Page' + ) { + $artifactGuid = $message['params']['initializer']['video']['guid']; + } } - return new Page($this, $pageGuid, $frameGuid); + return new Page($this, $pageGuid, $frameGuid, $artifactGuid); } /** diff --git a/src/Playwright/Page.php b/src/Playwright/Page.php index d97f3db3..84c35e6a 100644 --- a/src/Playwright/Page.php +++ b/src/Playwright/Page.php @@ -39,6 +39,7 @@ public function __construct( private readonly Context $context, private readonly string $guid, private readonly string $frameGuid, + private readonly ?string $artifactGuid = null, ) { // } @@ -51,6 +52,42 @@ public function context(): Context return $this->context; } + /** + * Save the recorded video to the given absolute path. + * This calls Playwright's Artifact.saveAs and blocks until the file is written. + * Returns false if no video artifact is associated with this page. + */ + public function saveVideo(string $destPath): bool + { + if ($this->artifactGuid === null) { + return false; + } + + $response = Client::instance()->execute($this->artifactGuid, 'saveAs', ['path' => $destPath]); + + foreach ($response as $_) { + // consume until done + } + + return true; + } + + /** + * Delete the recorded video artifact (used when test passes). + */ + public function deleteVideo(): void + { + if ($this->artifactGuid === null) { + return; + } + + $response = Client::instance()->execute($this->artifactGuid, 'delete'); + + foreach ($response as $_) { + // consume + } + } + /** * Get the current URL of the page. */ @@ -480,7 +517,6 @@ public function brokenImages(): array /** @var array $brokenImages */ return $brokenImages; - } /** @@ -541,7 +577,8 @@ public function expectScreenshot(bool $fullPage, bool $openDiff): void $openDiff ); - throw new ExpectationFailedException(<<<'EOT' + throw new ExpectationFailedException( + <<<'EOT' Screenshot does not match the last one. - Expected? Update the snapshots with [--update-snapshots]. - Not expected? Re-run the test with [--diff] to see the differences. @@ -558,7 +595,8 @@ public function expectScreenshot(bool $fullPage, bool $openDiff): void $openDiff, ); - throw new ExpectationFailedException(<<<'EOT' + throw new ExpectationFailedException( + <<<'EOT' Screenshot does not match the last one. - Expected? Update the snapshots with [--update-snapshots]. EOT, @@ -571,9 +609,11 @@ public function expectScreenshot(bool $fullPage, bool $openDiff): void */ public function close(): void { - if ($this->context->browser()->isClosed() + if ( + $this->context->browser()->isClosed() || $this->context->isClosed() - || $this->closed) { + || $this->closed + ) { return; } diff --git a/src/Playwright/Playwright.php b/src/Playwright/Playwright.php index c0cffae1..c1221dc5 100644 --- a/src/Playwright/Playwright.php +++ b/src/Playwright/Playwright.php @@ -59,6 +59,21 @@ final class Playwright */ private static ?string $host = null; + /** + * Whether to record video on failure. + */ + private static bool $recordVideoOnFailure = false; + + /** + * The page being recorded for the current test. + */ + private static ?Page $pendingVideoPage = null; + + /** + * The sanitized test name used as the video destination filename. + */ + private static ?string $pendingVideoDestName = null; + /** * Get a browser factory for the given browser type. */ @@ -187,6 +202,56 @@ public static function shouldDebugAssertions(): bool return self::$shouldDebugAssertions; } + /** + * Enable video recording on failure. + */ + public static function setRecordVideoOnFailure(): void + { + self::$recordVideoOnFailure = true; + } + + /** + * Whether video recording on failure is enabled. + */ + public static function shouldRecordVideoOnFailure(): bool + { + return self::$recordVideoOnFailure; + } + + /** + * Register a video recording for the current test. + */ + public static function registerVideoRecording(Page $page, string $destName): void + { + self::$pendingVideoPage = $page; + self::$pendingVideoDestName = $destName; + } + + /** + * Get the pending video page. + */ + public static function pendingVideoPage(): ?Page + { + return self::$pendingVideoPage; + } + + /** + * Get the pending video destination filename (without extension). + */ + public static function pendingVideoDestName(): ?string + { + return self::$pendingVideoDestName; + } + + /** + * Clear the pending video recording state. + */ + public static function clearVideoRecording(): void + { + self::$pendingVideoPage = null; + self::$pendingVideoDestName = null; + } + /** * Reset playwright state, reset browser types, without closing them. */ diff --git a/src/Plugin.php b/src/Plugin.php index 90ff64ca..7f1ab18d 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -11,6 +11,7 @@ use Pest\Browser\Exceptions\OptionNotSupportedInParallelException; use Pest\Browser\Filters\UsesBrowserTestCaseMethodFilter; use Pest\Browser\Playwright\Playwright; +use Pest\Browser\Support\Video; use Pest\Contracts\Plugins\Bootable; use Pest\Contracts\Plugins\HandlesArguments; use Pest\Contracts\Plugins\Terminable; @@ -50,9 +51,35 @@ public function boot(): void } } + $videoPage = Playwright::pendingVideoPage(); + $videoDestName = Playwright::pendingVideoDestName(); + ServerManager::instance()->http()->flush(); Playwright::reset(); + + if ($videoPage instanceof \Pest\Browser\Playwright\Page && $videoDestName !== null) { + /** @var TestStatus $videoStatus */ + $videoStatus = $this->status(); // @phpstan-ignore-line + + if ($videoStatus->isFailure() || $videoStatus->isError()) { + if (is_dir(Video::dir()) === false) { + mkdir(Video::dir(), 0755, true); + } + + $destFile = Video::dir().DIRECTORY_SEPARATOR.$videoDestName.'.webm'; + + if (file_exists($destFile)) { + unlink($destFile); + } + + $videoPage->saveVideo($destFile); + } else { + $videoPage->deleteVideo(); + } + + Playwright::clearVideoRecording(); + } })->in($this->in()); } @@ -69,6 +96,12 @@ public function handleArguments(array $arguments): array $arguments = $this->popArgument('--headed', $arguments); } + if ($this->hasArgument('--record-video', $arguments)) { + Playwright::setRecordVideoOnFailure(); + + $arguments = $this->popArgument('--record-video', $arguments); + } + if ($this->hasArgument('--diff', $arguments)) { Playwright::setShouldDiffOnScreenshotAssertions(); @@ -107,7 +140,7 @@ public function handleArguments(array $arguments): array if (($browser = BrowserType::tryFrom($browser)) === null) { throw new BrowserNotSupportedException( 'The specified browser type is not supported. Supported types are: '. - implode(', ', array_map(fn (BrowserType $type): string => mb_strtolower($type->name), BrowserType::cases())) + implode(', ', array_map(fn (BrowserType $type): string => mb_strtolower($type->name), BrowserType::cases())) ); } diff --git a/src/Support/Video.php b/src/Support/Video.php new file mode 100644 index 00000000..15c33aef --- /dev/null +++ b/src/Support/Video.php @@ -0,0 +1,22 @@ +rootPath + .'/tests/Browser/Videos'; + } +} diff --git a/tests/Unit/Configuration/VideoConfigurationTest.php b/tests/Unit/Configuration/VideoConfigurationTest.php new file mode 100644 index 00000000..833ad44c --- /dev/null +++ b/tests/Unit/Configuration/VideoConfigurationTest.php @@ -0,0 +1,50 @@ +getProperty('recordVideoOnFailure'); + $flag->setValue(null, false); + + $page = $ref->getProperty('pendingVideoPage'); + $page->setValue(null, null); + + $name = $ref->getProperty('pendingVideoDestName'); + $name->setValue(null, null); +}); + +it('returns false for recordVideoOnFailure by default', function (): void { + expect(Playwright::shouldRecordVideoOnFailure())->toBeFalse(); +}); + +it('sets recordVideoOnFailure flag via Playwright', function (): void { + Playwright::setRecordVideoOnFailure(); + + expect(Playwright::shouldRecordVideoOnFailure())->toBeTrue(); +}); + +it('enables video recording via Configuration fluent API', function (): void { + $config = new Configuration(); + + $result = $config->recordVideoOnFailure(); + + expect($result)->toBeInstanceOf(Configuration::class); + expect(Playwright::shouldRecordVideoOnFailure())->toBeTrue(); +}); + +it('returns null for pendingVideoPage and pendingVideoDestName by default', function (): void { + expect(Playwright::pendingVideoPage())->toBeNull(); + expect(Playwright::pendingVideoDestName())->toBeNull(); +}); + +it('clears video recording state', function (): void { + Playwright::clearVideoRecording(); + + expect(Playwright::pendingVideoPage())->toBeNull(); + expect(Playwright::pendingVideoDestName())->toBeNull(); +}); diff --git a/tests/Unit/Support/VideoTest.php b/tests/Unit/Support/VideoTest.php new file mode 100644 index 00000000..7f0446f8 --- /dev/null +++ b/tests/Unit/Support/VideoTest.php @@ -0,0 +1,13 @@ +toEndWith('/tests/Browser/Videos'); +}); + +it('videos directory is under tests/Browser', function (): void { + expect(Video::dir())->toContain('tests/Browser/Videos'); +});