Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ coverage.xml
# Playwright
node_modules/
/tests/Browser/Screenshots
/tests/Browser/Videos

# MacOS
.DS_Store
31 changes: 27 additions & 4 deletions src/Api/PendingAwaitablePage.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
Expand Down
10 changes: 10 additions & 0 deletions src/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
6 changes: 6 additions & 0 deletions src/Exceptions/BrowserExpectationFailedException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
14 changes: 12 additions & 2 deletions src/Playwright/Context.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand All @@ -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);
}

/**
Expand Down
50 changes: 45 additions & 5 deletions src/Playwright/Page.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public function __construct(
private readonly Context $context,
private readonly string $guid,
private readonly string $frameGuid,
private readonly ?string $artifactGuid = null,
) {
//
}
Expand All @@ -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.
*/
Expand Down Expand Up @@ -480,7 +517,6 @@ public function brokenImages(): array

/** @var array<int, string> $brokenImages */
return $brokenImages;

}

/**
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -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;
}

Expand Down
65 changes: 65 additions & 0 deletions src/Playwright/Playwright.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
*/
Expand Down
35 changes: 34 additions & 1 deletion src/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
}

Expand All @@ -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();

Expand Down Expand Up @@ -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()))
);
}

Expand Down
22 changes: 22 additions & 0 deletions src/Support/Video.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Pest\Browser\Support;

use Pest\TestSuite;

/**
* @internal
*/
final class Video
{
/**
* Return the path to the videos directory.
*/
public static function dir(): string
{
return TestSuite::getInstance()->rootPath
.'/tests/Browser/Videos';
}
}
Loading