diff --git a/src/Api/Webpage.php b/src/Api/Webpage.php index d42876a7..332d1f87 100644 --- a/src/Api/Webpage.php +++ b/src/Api/Webpage.php @@ -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 @@ -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(); + } } diff --git a/src/Playwright/Clock.php b/src/Playwright/Clock.php new file mode 100644 index 00000000..ef065ab5 --- /dev/null +++ b/src/Playwright/Clock.php @@ -0,0 +1,138 @@ +|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 $params + */ + private function sendMessage(string $method, array $params = []): Generator + { + return Client::instance()->execute($this->targetGuid, $method, $params); + } +} diff --git a/src/Playwright/Page.php b/src/Playwright/Page.php index d97f3db3..68a0530b 100644 --- a/src/Playwright/Page.php +++ b/src/Playwright/Page.php @@ -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. */ diff --git a/tests/Browser/Webpage/ClockTest.php b/tests/Browser/Webpage/ClockTest.php new file mode 100644 index 00000000..cc77fe87 --- /dev/null +++ b/tests/Browser/Webpage/ClockTest.php @@ -0,0 +1,111 @@ + ' +
+ + '); + + $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 => '
'); + + $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 => ' +
0
+ + '); + + $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 => '
'); + + $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 => ' +
waiting
+ + '); + + $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'); +});