diff --git a/bin/lib/handlers.js b/bin/lib/handlers.js
index 13ebdd2..e39f72f 100644
--- a/bin/lib/handlers.js
+++ b/bin/lib/handlers.js
@@ -48,6 +48,44 @@ class ContextHandler extends BaseHandler {
return this.wrapResult(result);
}
+ async handleClock(command, method) {
+ const context = this.validateResource(this.contexts, command.contextId, 'Context')?.context;
+
+ const registry = CommandRegistry.create({
+ install: async () => {
+ await context.clock.install(command.options);
+ return {};
+ },
+ fastForward: async () => {
+ await context.clock.fastForward(command.ticks);
+ return {};
+ },
+ pauseAt: async () => {
+ await context.clock.pauseAt(command.time);
+ return {};
+ },
+ resume: async () => {
+ await context.clock.resume();
+ return {};
+ },
+ runFor: async () => {
+ await context.clock.runFor(command.ticks);
+ return {};
+ },
+ setFixedTime: async () => {
+ await context.clock.setFixedTime(command.time);
+ return {};
+ },
+ setSystemTime: async () => {
+ await context.clock.setSystemTime(command.time);
+ return {};
+ }
+ });
+
+ const result = await ErrorHandler.safeExecute(() => this.executeWithRegistry(registry, method), { method, contextId: command.contextId });
+ return this.wrapResult(result);
+ }
+
setThrottling(command) {
if (command.throttling && typeof command.throttling === 'object') {
this.contextThrottling.set(command.contextId, {
diff --git a/bin/playwright-server.js b/bin/playwright-server.js
index cccc6a7..597e5bd 100644
--- a/bin/playwright-server.js
+++ b/bin/playwright-server.js
@@ -73,7 +73,8 @@ class PlaywrightServer extends BaseHandler {
touchscreen: () => this.interactionHandler.handleTouchscreen(command, actionMethod),
frame: () => this.frameHandler.handle(command, actionMethod),
browserServer: () => this.handleBrowserServer(command, actionMethod),
- selectors: () => this.selectorsHandler.handle(command, actionMethod)
+ selectors: () => this.selectorsHandler.handle(command, actionMethod),
+ clock: () => this.contextHandler.handleClock(command, actionMethod)
});
if (handlerRegistry.has(actionPrefix)) {
diff --git a/tests/Fixtures/html/clock.html b/tests/Fixtures/html/clock.html
new file mode 100644
index 0000000..a4b992d
--- /dev/null
+++ b/tests/Fixtures/html/clock.html
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+ idle
+
+
+
+
+ idle
+
+
+
+
+
diff --git a/tests/Functional/Clock/ClockTest.php b/tests/Functional/Clock/ClockTest.php
new file mode 100644
index 0000000..4673888
--- /dev/null
+++ b/tests/Functional/Clock/ClockTest.php
@@ -0,0 +1,134 @@
+context->clock()->install(['time' => $startTime]);
+
+ $this->goto('/clock.html');
+
+ $this->assertTimeNear('#time', $startTime, 1000);
+ }
+
+ public function testSetFixedTimeMakesDateNowFixed(): void
+ {
+ $startTime = 1704067200000;
+ $this->context->clock()->install(['time' => 0]);
+ $this->context->clock()->setFixedTime($startTime);
+
+ $this->goto('/clock.html');
+
+ $this->expect($this->page->locator('#time'))->toHaveText((string) $startTime);
+ }
+
+ public function testSetSystemTimeChangesDateNow(): void
+ {
+ $systemTime = 1704067200000;
+ $this->context->clock()->install(['time' => 0]);
+
+ $this->goto('/clock.html');
+
+ $this->context->clock()->setSystemTime($systemTime);
+ $this->page->click('#btn');
+
+ $this->assertTimeNear('#time', $systemTime, 1000);
+ }
+
+ public function testPauseAtPausesClock(): void
+ {
+ $startTime = 1_600_000_000_000;
+ $this->context->clock()->install(['time' => $startTime]);
+
+ $this->goto('/clock.html');
+
+ $targetTime = $startTime + 5_000;
+ $this->context->clock()->pauseAt($targetTime);
+ $this->page->click('#btn');
+
+ $this->expect($this->page->locator('#time'))->toHaveText((string) $targetTime);
+ }
+
+ public function testFastForwardAdvancesTimers(): void
+ {
+ $startTime = 1_600_000_000_000;
+ $this->context->clock()->install(['time' => $startTime]);
+
+ $this->goto('/clock.html');
+ $this->page->click('#schedule-long-timeout');
+ $this->expect($this->page->locator('#long-timeout-status'))->toContainText('waiting');
+
+ $this->context->clock()->fastForward(5000);
+
+ $this->assertTimeNear('#long-timeout-status', $startTime + 5000, 200);
+ }
+
+ public function testRunForAdvancesTimers(): void
+ {
+ $startTime = 1_600_000_000_000;
+ $this->context->clock()->install(['time' => $startTime]);
+
+ $this->goto('/clock.html');
+ $this->page->click('#schedule-short-timeout');
+ $this->expect($this->page->locator('#short-timeout-status'))->toContainText('waiting');
+
+ $this->context->clock()->runFor(2000);
+
+ $this->assertTimeNear('#short-timeout-status', $startTime + 2000, 200);
+ }
+
+ public function testResumeResumesFromPause(): void
+ {
+ $startTime = 1_600_000_000_000;
+ $this->context->clock()->install(['time' => $startTime]);
+
+ $this->goto('/clock.html');
+ $status = $this->page->locator('#short-timeout-status');
+ $this->page->click('#schedule-short-timeout');
+ $this->expect($status)->toHaveText('waiting');
+
+ $this->context->clock()->pauseAt($startTime + 1000);
+ $this->context->clock()->runFor(500);
+ $this->expect($status)->toHaveText('waiting');
+
+ $this->context->clock()->resume();
+ $this->context->clock()->runFor(1000);
+
+ $this->assertTimeNear('#short-timeout-status', $startTime + 2000, 300);
+ }
+
+ private function assertTimeNear(string $selector, int $expected, int $tolerance = 200): void
+ {
+ $text = $this->page->locator($selector)->textContent();
+ self::assertIsString($text, sprintf('Element %s did not contain time text', $selector));
+
+ $actual = (int) trim($text);
+ $diff = abs($actual - $expected);
+
+ self::assertLessThanOrEqual(
+ $tolerance,
+ $diff,
+ sprintf('Expected %s time near %d (±%d), got %d (diff %d)', $selector, $expected, $tolerance, $actual, $diff)
+ );
+ }
+}
diff --git a/tests/Unit/Clock/ClockTest.php b/tests/Unit/Clock/ClockTest.php
new file mode 100644
index 0000000..834c01b
--- /dev/null
+++ b/tests/Unit/Clock/ClockTest.php
@@ -0,0 +1,146 @@
+connect();
+ $transport->queueResponse([]);
+
+ $clock = new Clock($transport, 'ctx_ff_1');
+ $clock->fastForward(5000);
+
+ $sent = $transport->getSentMessages();
+ $this->assertCount(1, $sent);
+ $this->assertSame('clock.fastForward', $sent[0]['action']);
+ $this->assertSame('ctx_ff_1', $sent[0]['contextId']);
+ $this->assertSame(5000, $sent[0]['ticks']);
+ }
+
+ public function testFastForwardSendsStringTicks(): void
+ {
+ $transport = new MockTransport();
+ $transport->connect();
+ $transport->queueResponse([]);
+
+ $clock = new Clock($transport, 'ctx_ff_2');
+ $clock->fastForward('01:00:00');
+
+ $sent = $transport->getSentMessages();
+ $this->assertSame('clock.fastForward', $sent[0]['action']);
+ $this->assertSame('01:00:00', $sent[0]['ticks']);
+ }
+
+ public function testInstallForwardsOptionsIncludingDateTime(): void
+ {
+ $transport = new MockTransport();
+ $transport->connect();
+ $transport->queueResponse([]);
+
+ $clock = new Clock($transport, 'ctx_install_1');
+ $dt = new \DateTimeImmutable('2025-01-01T00:00:00Z');
+
+ $clock->install(['time' => $dt]);
+
+ $sent = $transport->getSentMessages();
+ $this->assertSame('clock.install', $sent[0]['action']);
+ $this->assertArrayHasKey('options', $sent[0]);
+ $this->assertSame($dt, $sent[0]['options']['time']);
+ }
+
+ public function testPauseAtConvertsDateTimeToMilliseconds(): void
+ {
+ $transport = new MockTransport();
+ $transport->connect();
+ $transport->queueResponse([]);
+
+ $clock = new Clock($transport, 'ctx_pause_1');
+ $dt = new \DateTimeImmutable('2025-02-02T12:34:56Z');
+
+ $clock->pauseAt($dt);
+
+ $sent = $transport->getSentMessages();
+ $this->assertSame('clock.pauseAt', $sent[0]['action']);
+ $this->assertSame($dt->getTimestamp() * 1000, $sent[0]['time']);
+ }
+
+ public function testResumeSendsMessage(): void
+ {
+ $transport = new MockTransport();
+ $transport->connect();
+ $transport->queueResponse([]);
+
+ $clock = new Clock($transport, 'ctx_resume_1');
+ $clock->resume();
+
+ $sent = $transport->getSentMessages();
+ $this->assertSame('clock.resume', $sent[0]['action']);
+ }
+
+ public function testRunForSendsTicks(): void
+ {
+ $transport = new MockTransport();
+ $transport->connect();
+ $transport->queueResponse([]);
+
+ $clock = new Clock($transport, 'ctx_runfor_1');
+ $clock->runFor(1234);
+
+ $sent = $transport->getSentMessages();
+ $this->assertSame('clock.runFor', $sent[0]['action']);
+ $this->assertSame(1234, $sent[0]['ticks']);
+ }
+
+ public function testSetFixedTimeConvertsDateTimeToMilliseconds(): void
+ {
+ $transport = new MockTransport();
+ $transport->connect();
+ $transport->queueResponse([]);
+
+ $clock = new Clock($transport, 'ctx_fixed_1');
+ $dt = new \DateTimeImmutable('2025-03-03T00:00:00Z');
+
+ $clock->setFixedTime($dt);
+
+ $sent = $transport->getSentMessages();
+ $this->assertSame('clock.setFixedTime', $sent[0]['action']);
+ $this->assertSame($dt->getTimestamp() * 1000, $sent[0]['time']);
+ }
+
+ public function testSetSystemTimeConvertsDateTimeToMilliseconds(): void
+ {
+ $transport = new MockTransport();
+ $transport->connect();
+ $transport->queueResponse([]);
+
+ $clock = new Clock($transport, 'ctx_system_1');
+ $dt = new \DateTimeImmutable('2025-04-04T00:00:00Z');
+
+ $clock->setSystemTime($dt);
+
+ $sent = $transport->getSentMessages();
+ $this->assertSame('clock.setSystemTime', $sent[0]['action']);
+ $this->assertSame($dt->getTimestamp() * 1000, $sent[0]['time']);
+ }
+}