diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 92c1939..44e5cc4 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -38,6 +38,7 @@ jobs: - dependencies: "lowest" stability: "stable" php-version: "7.2" + symfony-deprecations-helper: "weak" steps: - name: "Checkout" uses: "actions/checkout@v2" diff --git a/README.md b/README.md index 4db9af8..fb22f17 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ VCRBundle Integrates [php-vcr](https://github.com/php-vcr/php-vcr) into Symfony and its web profiler. +It also provides a VideoRecorderBrowser for testing purpose with extra helper methods handling php-vcr recordings. PHP-VCR Symfony web profiler panel PHP-VCR Symfony web profiler panel - request details @@ -20,41 +21,109 @@ composer require php-vcr/vcr-bundle And declare the bundle in your `config/bundles.php` file: ```php + ['test' => true], ]; +``` + +## Usage + +Enable the required library hooks for your purpose and write test cases. + +### VideoRecorderBrowser (without Trait) + +```php +getContainer()->get('test.client.vcr'); + + $client->insertVideoRecorderCassette('my-test-cassette-name'); + + // this is an example, normally services inside you project do stuff like this and you trigger them by + // execute requests via the KernelBrowser client + file_get_contents('https://www.google.de'); + + // cassette.path is configured to '%kernel.project_dir%/tests/Fixtures' + // recordings are written to %kernel.project_dir%/tests/Fixtures/my-test-cassette-name + // cassette.path + cassetteName (done by inserting the cassette) + } +} +``` + +### VideoRecorderBrowser (with Trait) + +```php +setUpTestSuiteName(); + + return $this->testSuiteName; + } + + /** + * @beforeClass + * + * @return void + */ + protected function setUpTestSuiteName(): void + { + if (! $this->testSuiteName) { + $testSuiteName = (new \ReflectionClass($this))->getName(); + if ( + ! empty($this->ignoredTestSuiteNamespacePrefix) && + str_starts_with($testSuiteName, $this->ignoredTestSuiteNamespacePrefix) + ) { + $testSuiteName = str_replace($this->ignoredTestSuiteNamespacePrefix, '', $testSuiteName); + } + $testSuiteNameParts = array_filter(explode('\\', $testSuiteName)); + $this->testSuiteName = implode(DIRECTORY_SEPARATOR, $testSuiteNameParts); + } + } + + /** + * Normalize a video recorder cassette name. + * + * @param string $name The name to normalize + * + * @return string The normalized cassette name + */ + protected function normalizeVideoRecorderCassetteName(string $name): string + { + return preg_replace(['/\s+/', '/[^a-zA-Z0-9\-_]/'], ['-', ''], $name); + } + + /** + * Get the video recorder cassette name from the current test. + * + * @param null|string $suffix + * @param bool $withDataSet With dataset + * + * @return string + */ + protected function getVideoRecorderCassetteName(string $suffix = null, bool $withDataSet = true): string + { + $name = $this->getName($withDataSet); + if (! empty($suffix)) { + $name .= $suffix; + } + + return $this->normalizeVideoRecorderCassetteName($name); + } + + /** + * Enable VCR VideoRecorder. + * + * @param ContainerInterface $container + * @param string|null $suffix + * + * @param bool $withDataSet + * + * @return void + */ + protected function enableVideoRecorder( + ContainerInterface $container, + string $suffix = null, + bool $withDataSet = true + ): void { + $videoRecorder = $this->getVideoRecorder($container); + $videoRecorder->turnOn(); + $this->insertVideoRecorderCassette($container, $suffix, $withDataSet); + } + + /** + * Insert cassette into VCR VideoRecorder. + * + * @param ContainerInterface $container + * @param null|string $suffix + * + * @param bool $withDataSet + * + * @return void + */ + protected function insertVideoRecorderCassette( + ContainerInterface $container, + string $suffix = null, + bool $withDataSet = true + ): void { + $name = $this->getVideoRecorderCassetteName($suffix, $withDataSet); + $name = implode( + DIRECTORY_SEPARATOR, + [ + $this->getTestSuiteName(), + $name, + ] + ); + + $videoRecorder = $this->getVideoRecorder($container); + + $videoRecorder->insertCassette($name); + } + + /** + * Insert default cassette into VCR VideoRecorder. + * + * @param ContainerInterface $container + * + * @return void + */ + protected function insertDefaultVideoRecorderCassette(ContainerInterface $container): void + { + $defaultCassetteName = $container->getParameter('vcr.cassette.name'); + + $this->getVideoRecorder($container)->insertCassette($defaultCassetteName); + } + + /** + * Disable VCR VideoRecorder. + * + * @param ContainerInterface $container + * + * @return void + */ + protected function disableVideoRecorder(ContainerInterface $container): void + { + $this->getVideoRecorder($container)->turnOff(); + } + + /** + * @param ContainerInterface $container + * + * @return Videorecorder + */ + protected function getVideoRecorder(ContainerInterface $container): Videorecorder + { + return $container->get('vcr.recorder'); + } + + /** + * Creates a Client supports the VideoRecorder framework. + * + * @param array $options An array of options to pass to the createKernel method + * @param array $server An array of server parameters + * @param null|string $suffix + * @param bool $withDataSet + * + * @return VideoRecorderBrowser A KernelBrowser instance + */ + protected function createVideoRecorderClient( + array $options = [], + array $server = [], + string $suffix = null, + bool $withDataSet = true + ) { + if (! \method_exists(\get_called_class(), 'createClient')) { + throw new \LogicException( + 'Current test case has no static method to createClient(array $options, array $server).' + ); + } + + /** @var KernelBrowser $client */ + $client = static::createClient($options, $server); + try { + /** @var VideoRecorderBrowser $client */ + $client = $client->getKernel()->getContainer()->get('test.client.vcr'); + } catch (ServiceNotFoundException $e) { + throw new \LogicException('VideoRecorderBrowser not loaded. Did you enable the this bundle?'); + } + + $client->setVideoRecorderCassetteBasePath($this->getTestSuiteName()); + $client->enableVideoRecorder(); + $client->insertVideoRecorderCassette($this->getVideoRecorderCassetteName($suffix, $withDataSet)); + + return $client; + } +} diff --git a/src/VCRBundle.php b/src/VCRBundle.php index 76a174d..c2dc024 100644 --- a/src/VCRBundle.php +++ b/src/VCRBundle.php @@ -3,8 +3,10 @@ namespace VCR\VCRBundle; +use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\HttpKernel\Bundle\Bundle; +use VCR\Videorecorder; class VCRBundle extends Bundle { @@ -17,12 +19,33 @@ public function boot(): void $fs->mkdir($cassettePath); } - if ($this->container->getParameter('vcr.enabled')) { - $recorder = $this->container->get('vcr.recorder'); + if ($this->isEnabled()) { + $recorder = $this->getVideoRecorder(); $cassetteName = $this->container->getParameter('vcr.cassette.name'); $recorder->turnOn(); $recorder->insertCassette($cassetteName); } } + + public function shutdown(): void + { + if ($this->isEnabled()) { + $this->getVideoRecorder()->turnOff(); + } + } + + private function isEnabled(): bool + { + try { + return $this->container->getParameter('vcr.enabled'); + } catch (InvalidArgumentException $e) { + return false; + } + } + + private function getVideoRecorder(): Videorecorder + { + return $this->container->get('vcr.recorder'); + } } diff --git a/src/VideoRecorderBrowser.php b/src/VideoRecorderBrowser.php new file mode 100644 index 0000000..b02718e --- /dev/null +++ b/src/VideoRecorderBrowser.php @@ -0,0 +1,274 @@ +boot(); + if ($this->getContainer()->getParameter('vcr.enabled')) { + $this->vcrEnabled = true; + } + } + + /** + * @return null|string + */ + public function getVideoRecorderCassetteBasePath(): ?string + { + return $this->vcrCassetteBasePath; + } + + /** + * @param null|string $vcrCassetteBasePath + * + * @return void + */ + public function setVideoRecorderCassetteBasePath(?string $vcrCassetteBasePath) + { + if (! empty($vcrCassetteBasePath)) { + $vcrCassetteBasePath = trim($vcrCassetteBasePath, "\t\n\r\0\x0B" . DIRECTORY_SEPARATOR); + } else { + $vcrCassetteBasePath = null; + } + + $this->vcrCassetteBasePath = $vcrCassetteBasePath; + } + + /** + * Enable the video recorder for this client (& all its performed requests). + * + * @param null|string $cassetteName The cassette name to insert + * @param bool $insertDefaultCassette Insert the default cassette if non passed + * + * @return void + */ + public function enableVideoRecorder(?string $cassetteName = null, bool $insertDefaultCassette = true): void + { + if ($this->isVideoRecorderEnabled()) { + return; + } + + $this->getVideoRecorder()->turnOn(); + $this->vcrEnabled = true; + if (! empty($cassetteName)) { + $this->insertVideoRecorderCassette($cassetteName); + } elseif ($insertDefaultCassette) { + $this->insertDefaultVideoRecorderCassette(); + } + } + + /** + * Disable the video recorder for this client (&all its performed requests). + * + * @return void + */ + public function disableVideoRecorder(): void + { + $this->ejectVideoRecorderCassette(false); + $this->getVideoRecorder()->turnOff(); + $this->vcrEnabled = false; + } + + /** + * Is video recorder enabled? + * + * @return bool + */ + public function isVideoRecorderEnabled(): bool + { + return $this->vcrEnabled; + } + + /** + * Get the current video recorder. + * + * @return Videorecorder + */ + public function getVideoRecorder(): Videorecorder + { + $container = $this->getContainer(); + if (null === $container) { + throw new \LogicException('Kernel seems not to be booted, due container is not available.'); + } + + return $container->get('vcr.recorder'); + } + + /** + * @param string $cassetteName + * @param bool $force True if force + * + * @return void + */ + public function insertVideoRecorderCassette(string $cassetteName, bool $force = false) + { + if (! $this->isVideoRecorderEnabled()) { + throw new \LogicException('VideoRecorder seems not be enabled. Enable it first, then insert a cassette.'); + } + + if ($this->vcrCassetteName !== $cassetteName || $force) { + $resolvedCassetteName = [$this->vcrCassetteBasePath, $cassetteName]; + $resolvedCassetteName = \array_filter($resolvedCassetteName); + $resolvedCassetteName = \implode(DIRECTORY_SEPARATOR, $resolvedCassetteName); + $this->getVideoRecorder()->insertCassette($resolvedCassetteName); + $this->vcrCassetteName = $cassetteName; + } + } + + /** + * Ejects the current inserted video recorder cassette. + * + * @param bool $reInsertDefaultCassette + * + * @return void + */ + public function ejectVideoRecorderCassette(bool $reInsertDefaultCassette = true) + { + $this->getVideoRecorder()->eject(); + $this->vcrCassetteName = null; + if ($reInsertDefaultCassette) { + $this->insertDefaultVideoRecorderCassette(); + } + } + + /** + * The default video recorder cassette name. + * + * @return string The default video recorder cassette name + */ + protected function getDefaultVideoRecorderCassetteName(): string + { + $container = $this->getContainer(); + if (null === $container) { + throw new \LogicException('Kernel seems not to be bootet, due container is not available.'); + } + + return $container->getParameter('vcr.cassette.name'); + } + + /** + * Insert the default video recorder cassette. + * + * @return void + */ + protected function insertDefaultVideoRecorderCassette(): void + { + $cassetteName = $this->getDefaultVideoRecorderCassetteName(); + $this->getVideoRecorder()->insertCassette($cassetteName); + } + + /** + * Set a callable is called after container has been set up but before the request is performed to configure the + * video recorder used for this request. + * + * @param callable|null $callback + * + * @return void + */ + public function setVideoRecorderConfigureCallback(callable $callback = null): void + { + $this->vcrConfigureCallback = $callback; + } + + /** + * Remove existing video recorder configure callback. + * + * @return void + */ + public function removeVideoRecorderConfigureCallback(): void + { + $this->vcrConfigureCallback = null; + } + + /** + * @param Request $request + * + * @return Response + */ + protected function doRequest($request): Response + { + // this is a little bit hacky because the we need the parent properties, but they are private :-( + $propertyAccessor = function ($property) { + $funcArgs = \func_get_args(); + if (2 === count($funcArgs)) { + $this->$property = \func_get_arg(1); + } else { + return $this->$property; + } + }; + $propertyAccessor = $propertyAccessor->bindTo($this, BaseBrowser::class); + + $shouldReboot = $propertyAccessor('reboot'); + $hasPerformedRequest = $propertyAccessor('hasPerformedRequest'); + if ($this->isVideoRecorderEnabled() && $hasPerformedRequest && $shouldReboot) { + $this->disableReboot(); + + $kernel = $this->getKernel(); + $kernel->shutdown(); + $kernel->boot(); + + $this->enableVideoRecorder(); + $this->insertVideoRecorderCassette($this->vcrCassetteName, true); + } + + if ($this->vcrConfigureCallback instanceof \Closure) { + $vcrCallable = \Closure::bind($this->vcrConfigureCallback, $this); + \call_user_func($vcrCallable, $this->getVideoRecorder(), $this->getContainer()); + } + + $response = parent::doRequest($request); + + if ($this->isVideoRecorderEnabled() && $hasPerformedRequest && $shouldReboot) { + $this->enableReboot(); + } + + return $response; + } +} diff --git a/tests/Functional/VCRTestCaseTraitTest.php b/tests/Functional/VCRTestCaseTraitTest.php new file mode 100644 index 0000000..494f95d --- /dev/null +++ b/tests/Functional/VCRTestCaseTraitTest.php @@ -0,0 +1,49 @@ +getContainer(); + + $basePath = $container->getParameter('vcr.cassette.path'); + $testName = 'test'; + + $fixturePath = implode('/', [$basePath, $this->testSuitePath, $testName]); + + $this->enableVideoRecorder($container); + $this->insertVideoRecorderCassette($container); + + file_get_contents('https://www.google.de'); + + static::assertFileExists($fixturePath); + } + + public function testVideoRecorderBrowser(): void + { + $client = static::createVideoRecorderClient(); + static::assertInstanceOf(VideoRecorderBrowser::class, $client); + + $container = $client->getKernel()->getContainer(); + $basePath = $container->getParameter('vcr.cassette.path'); + $testName = 'testVideoRecorderBrowser'; + $fixturePath = implode('/', [$basePath, $this->testSuitePath, $testName]); + + file_get_contents('https://www.google.de'); + + static::assertFileExists($fixturePath); + } +} diff --git a/tests/Functional/VideoRecorderBrowserTest.php b/tests/Functional/VideoRecorderBrowserTest.php new file mode 100644 index 0000000..bda62d6 --- /dev/null +++ b/tests/Functional/VideoRecorderBrowserTest.php @@ -0,0 +1,34 @@ +getContainer(); + $basePath = $container->getParameter('vcr.cassette.path'); + $fixturesPath = $basePath . '/' . $cassetteName; + + static::assertTrue( + $container->has($serviceId), + 'VideoRecorderBrowser (Client) service seems not to be registered.' + ); + + $client = $container->get($serviceId); + static::assertInstanceOf(VideoRecorderBrowser::class, $client); + + $client->insertVideoRecorderCassette($cassetteName); + file_get_contents('https://www.google.de'); + + static::assertFileExists($fixturesPath); + } +} diff --git a/tests/Functional/app/config/Default/config.yml b/tests/Functional/app/config/Default/config.yml index bd5053e..8153724 100644 --- a/tests/Functional/app/config/Default/config.yml +++ b/tests/Functional/app/config/Default/config.yml @@ -1,4 +1,3 @@ framework: secret: ThisIsVerySecret! test: ~ - diff --git a/tests/Functional/app/config/VideoRecorderBrowser/config.yml b/tests/Functional/app/config/VideoRecorderBrowser/config.yml new file mode 100644 index 0000000..7a8546c --- /dev/null +++ b/tests/Functional/app/config/VideoRecorderBrowser/config.yml @@ -0,0 +1,8 @@ +imports: + - { resource: '../Default/config.yml' } + +vcr: + library_hooks: + stream_wrapper: true + cassette: + type: yaml