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
12 changes: 11 additions & 1 deletion src/Api/Webpage.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
namespace Pest\Browser\Api;

use Pest\Browser\Execution;
use Pest\Browser\Playwright\Locator;
use Pest\Browser\Playwright\Page;
use Pest\Browser\Playwright\Clock;
use Pest\Browser\Playwright\Locator;
use Pest\Browser\Support\GuessLocator;

final readonly class Webpage
Expand Down Expand Up @@ -102,4 +103,13 @@ private function guessLocator(string $selector, ?string $value = null): Locator
{
return (new GuessLocator($this->page))->for($selector, $value);
}

/**
* Get the clock instance for controlling time in tests.
* The clock is installed for the entire BrowserContext.
*/
public function clock(): Clock
{
return $this->page->clock();
}
}
138 changes: 138 additions & 0 deletions src/Playwright/Clock.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
<?php

declare(strict_types=1);

namespace Pest\Browser\Playwright;

use DateTimeInterface;
use Generator;
use Pest\Browser\Playwright\Concerns\InteractsWithPlaywright;

/**
* @internal
*/
final readonly class Clock
{
use InteractsWithPlaywright;

/**
* Creates a new clock instance.
*/
public function __construct(
private string $targetGuid,
) {
//
}

/**
* Install fake implementations for time-related functions.
*
* @param array<string, mixed>|null $options Options including time to initialize with
*/
public function install(?array $options = null): void
{
$params = [];

if (isset($options['time'])) {
$params['time'] = $this->normalizeTime($options['time']);
}

$response = $this->sendMessage('clockInstall', $params);

$this->processVoidResponse($response);
}

/**
* Advance the clock, firing all the time-related callbacks.
*
* @param int|string $ticks Time in milliseconds or human-readable string like "30:00"
*/
public function runFor(int|string $ticks): void
{
$response = $this->sendMessage('clockRunFor', ['ticks' => $ticks]);

$this->processVoidResponse($response);
}

/**
* Advance the clock by jumping forward in time.
* Only fires due timers at most once.
*
* @param int|string $ticks Time in milliseconds or human-readable string like "30:00"
*/
public function fastForward(int|string $ticks): void
{
$response = $this->sendMessage('clockFastForward', ['ticks' => $ticks]);

$this->processVoidResponse($response);
}

/**
* Advance the clock by jumping forward in time and pause the time.
* Once called, no timers are fired unless other clock methods are called.
*
* @param int|string|DateTimeInterface $time Time to pause at
*/
public function pauseAt(int|string|DateTimeInterface $time): void
{
$response = $this->sendMessage('clockPauseAt', ['time' => $this->normalizeTime($time)]);

$this->processVoidResponse($response);
}

/**
* Resumes timers. Once called, time resumes flowing and timers fire as usual.
*/
public function resume(): void
{
$response = $this->sendMessage('clockResume');

$this->processVoidResponse($response);
}

/**
* Makes Date.now and new Date() return fixed fake time at all times.
* Keeps all the timers running.
*
* @param int|string|DateTimeInterface $time Time to be set
*/
public function setFixedTime(int|string|DateTimeInterface $time): void
{
$response = $this->sendMessage('clockSetFixedTime', ['time' => $this->normalizeTime($time)]);

$this->processVoidResponse($response);
}

/**
* Sets system time but does not trigger any timers.
* Use this to test how the web page reacts to a time shift.
*
* @param int|string|DateTimeInterface $time Time to be set
*/
public function setSystemTime(int|string|DateTimeInterface $time): void
{
$response = $this->sendMessage('clockSetSystemTime', ['time' => $this->normalizeTime($time)]);

$this->processVoidResponse($response);
}

/**
* Normalize time parameter to the format expected by Playwright.
*/
private function normalizeTime(int|string|DateTimeInterface $time): int|string
{
if ($time instanceof DateTimeInterface) {
return $time->format('c'); // ISO 8601 format
}

return $time;
}

/**
* @param array<string, mixed> $params
*/
private function sendMessage(string $method, array $params = []): Generator
{
return Client::instance()->execute($this->targetGuid, $method, $params);
}
}
9 changes: 9 additions & 0 deletions src/Playwright/Page.php
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,15 @@ public function expectScreenshot(bool $fullPage, bool $openDiff): void
}
}

/**
* Get the clock instance for controlling time in tests.
* The clock is installed for the entire BrowserContext.
*/
public function clock(): Clock
{
return new Clock($this->guid);
}

/**
* Closes the page.
*/
Expand Down
111 changes: 111 additions & 0 deletions tests/Browser/Webpage/ClockTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

declare(strict_types=1);

use DateTimeImmutable;
use Illuminate\Support\Facades\Route;

it('can install clock and advance time with runFor', function (): void {
Route::get('/', fn (): string => '
<div id="timestamp"></div>
<script>
document.getElementById("timestamp").textContent = Date.now();
</script>
');

$page = visit('/');

// Install clock with a specific time
$page->clock()->install(['time' => new DateTimeImmutable('2021-01-01 00:00:00')]);

// Reload to see the fake time
$page->reload();

$initialTime = (int) $page->evaluate('() => Date.now()');

// Advance time by 5 seconds (5000ms)
$page->clock()->runFor(5000);

$newTime = (int) $page->evaluate('() => Date.now()');

expect($newTime - $initialTime)->toBe(5000);
});

it('can set fixed time', function (): void {
Route::get('/', fn (): string => '<div></div>');

$page = visit('/');

$fixedTime = new DateTimeImmutable('2021-01-01 00:00:00');

$page->clock()->setFixedTime($fixedTime);

$currentTime = (int) $page->evaluate('() => Date.now()');

expect($currentTime)->toBe($fixedTime->getTimestamp() * 1000);
});

it('can pause and resume timers', function (): void {
Route::get('/', fn (): string => '
<div id="counter">0</div>
<script>
let counter = 0;
setInterval(() => {
counter++;
document.getElementById("counter").textContent = counter;
}, 1000);
</script>
');

$page = visit('/');

$page->clock()->install();
$page->reload();

// Pause time at current moment
$currentTime = (int) $page->evaluate('() => Date.now()');
$page->clock()->pauseAt($currentTime);

// Wait a bit and run timers manually
$page->clock()->runFor(3000); // 3 seconds

$counter = (int) $page->evaluate('() => document.getElementById("counter").textContent');

expect($counter)->toBe(3); // Should have ticked 3 times
});

it('can work with DateTimeInterface', function (): void {
Route::get('/', fn (): string => '<div></div>');

$page = visit('/');

$dateTime = new DateTimeImmutable('2021-01-01T10:30:00Z');
$page->clock()->setFixedTime($dateTime);

$isoString = (string) $page->evaluate('() => new Date().toISOString()');

expect($isoString)->toBe('2021-01-01T10:30:00.000Z');
});

it('can fast forward time', function (): void {
Route::get('/', fn (): string => '
<div id="result">waiting</div>
<script>
setTimeout(() => {
document.getElementById("result").textContent = "done";
}, 10000); // 10 seconds
</script>
');

$page = visit('/');

$page->clock()->install();
$page->reload();

// Fast forward 15 seconds
$page->clock()->fastForward(15000);

$result = (string) $page->evaluate('() => document.getElementById("result").textContent');

expect($result)->toBe('done');
});