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']); + } +}