diff --git a/README.md b/README.md index b732bc9..484009a 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ composer require --dev tr33m4n/codeception-module-percy ``` ## Example Configuration -The following example configuration assumes the `WebDriver` module has been configured correctly for your test suite +The following example `acceptance.suite.yml` configuration assumes the `WebDriver` module has been configured correctly for your test suite and +shows enabling the Percy module and setting some basic configuration: ```yaml modules: enabled: @@ -27,26 +28,35 @@ modules: - 320 minHeight: 1080 ``` +The following example shows how to configure the `percy:process-snapshots` in the `codeception.yml` file: +```yaml +extensions: + commands: + - Codeception\Module\Percy\Command\ProcessSnapshots +``` ### Configuration Options -| Parameter | Type | Default | Description | -|------------------------------------|------------|------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `snapshotBaseUrl` | string | `http://localhost:5338` | The base URL used for operations within the Percy agent | -| `snapshotPath` | string | `percy/snapshot` | The path relative to the agent endpoint to post a snapshot to | -| `snapshotConfig` | object | `{}` | Additional configuration to pass to the "snapshot" functionality | -| `snapshotConfig.percyCSS` | string | `null` | Percy specific CSS to apply to the "snapshot" | -| `snapshotConfig.minHeight` | int | `null` | Minimum height of the resulting "snapshot" in pixels | -| `snapshotConfig.enableJavaScript` | bool | `false` | Enable JavaScript in the Percy rendering environment | -| `snapshotConfig.widths` | array | `null` | An array of integers representing the browser widths at which you want to take snapshots | -| `serializeConfig` | object | `{"enableJavaScript": true}` | Additional configuration to pass to the `PercyDOM.serialize` method injected into the web driver DOM | -| `snapshotServerTimeout` | int\ | `null` | [debug] The length of the time the Percy snapshot server will listen for incoming snapshots and send on to Percy.io (the amount of time needed to send all snapshots after a successful test suite run). No timeout is set by default | -| `throwOnAdapterError` | bool | `false` | [debug] Throw exception on adapter error | -| `cleanSnapshotStorage` | bool | `false` | [debug] Clean stored snapshot HTML after run | +| Parameter | Type | Default | Description | +|------------------------------------|-----------|------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `snapshotConfig` | object | `{}` | Additional configuration to pass to the "snapshot" functionality | +| `snapshotConfig.percyCSS` | string | `null` | Percy specific CSS to apply to the "snapshot" | +| `snapshotConfig.minHeight` | int | `null` | Minimum height of the resulting "snapshot" in pixels | +| `snapshotConfig.enableJavaScript` | bool | `false` | Enable JavaScript in the Percy rendering environment | +| `snapshotConfig.widths` | array | `null` | An array of integers representing the browser widths at which you want to take snapshots | +| `serializeConfig` | object | `{"enableJavaScript": true}` | Additional configuration to pass to the `PercyDOM.serialize` method injected into the web driver DOM | +| `collectOnly` | bool | `false` | Setting this to `true` will only collect snapshots, rather than collect and then send at the end of the run. They can then be sent manually by calling the `vendor/bin/codecept percy:process-snapshots` command | +| `snapshotServerTimeout` | int | `null` | [debug] The length of the time the Percy snapshot server will listen for incoming snapshots and send on to Percy.io (the amount of time needed to send all snapshots after a successful test suite run). No timeout is set by default | +| `snapshotServerPort` | int | `5338` | [debug] The port the Percy snapshot server will listen on | +| `throwOnAdapterError` | bool | `false` | [debug] Throw exception on adapter error | +| `instanceId` | string | `null` | [debug] An ID is used to differentiate between one Codeception runs output files to another, ensuring only the current runs output files are cleared on failure. Use this config to pass a custom instance ID | ## Running The Percy integration runs automatically with the test suite but will need your `PERCY_TOKEN` to be set to successfully send snapshots. For more information, see https://docs.percy.io/docs/environment-variables#section-required ### Overriding the `node` path By default, the `node` executable used will be the one defined within the `PATH` of the user running the test suite. This can be overridden however, by setting the environment variable `PERCY_NODE_PATH` to your preferred location. +### Collect only +In some advanced CI setups, it might make sense to collect all snapshots for multiple runs with different parameters and then send them a single time when all runs are complete. This can be achieved by setting the `collectOnly` config to `true`. Once all runs are complete, running the command `vendor/bin/codecept percy:process-snapshots` +will then iterate all collected snapshots, send to Percy and then clean up the snapshot folder ### Example Test ```php [ __DIR__ . '/src/Codeception/Module/Percy/Exchange/Adapter/CurlAdapter.php', - __DIR__ . '/src/Codeception/Module/Percy/RequestManagement.php' + __DIR__ . '/src/Codeception/Module/Percy/RequestManagement.php', + __DIR__ . '/src/Codeception/Module/Percy/Snapshot.php' ], TypedPropertyRector::class => [ __DIR__ . '/src/Codeception/Module/Percy/Exchange/Adapter/CurlAdapter.php' diff --git a/src/Codeception/Module/Percy.php b/src/Codeception/Module/Percy.php index 98bcc53..e28e540 100644 --- a/src/Codeception/Module/Percy.php +++ b/src/Codeception/Module/Percy.php @@ -7,11 +7,10 @@ use Codeception\Lib\ModuleContainer; use Codeception\Module; use Codeception\Module\Percy\ConfigManagement; -use Codeception\Module\Percy\CreateSnapshot; -use Codeception\Module\Percy\Exchange\Payload; +use Codeception\Module\Percy\Definitions; use Codeception\Module\Percy\ProcessManagement; -use Codeception\Module\Percy\RequestManagement; use Codeception\Module\Percy\ServiceContainer; +use Codeception\Module\Percy\SnapshotManagement; use Codeception\TestInterface; use Exception; use Symfony\Component\Process\Exception\RuntimeException; @@ -26,38 +25,16 @@ */ class Percy extends Module { - public const NAMESPACE = 'Percy'; - - public const PACKAGE_NAME = 'tr33m4n/codeception-module-percy'; - /** * @var array */ - protected $config = [ - 'snapshotBaseUrl' => 'http://localhost:5338', - 'snapshotPath' => 'percy/snapshot', - 'serializeConfig' => [ - 'enableJavaScript' => true - ], - 'snapshotConfig' => [ - 'widths' => [ - 375, - 1280 - ], - 'minHeight' => 1024 - ], - 'snapshotServerTimeout' => null, - 'throwOnAdapterError' => false, - 'cleanSnapshotStorage' => false - ]; + protected $config = Definitions::DEFAULT_CONFIG; private ConfigManagement $configManagement; - private RequestManagement $requestManagement; - private ProcessManagement $processManagement; - private CreateSnapshot $createSnapshot; + private SnapshotManagement $snapshotManagement; private EnvironmentProviderInterface $environmentProvider; @@ -85,9 +62,8 @@ public function __construct( $serviceContainer = new ServiceContainer($webDriverModule, $percyModuleConfig); $this->configManagement = $serviceContainer->getConfigManagement(); - $this->requestManagement = $serviceContainer->getRequestManagement(); $this->processManagement = $serviceContainer->getProcessManagement(); - $this->createSnapshot = $serviceContainer->getCreateSnapshot(); + $this->snapshotManagement = $serviceContainer->getSnapshotManagement(); $this->environmentProvider = $serviceContainer->getEnvironmentProvider(); $this->webDriver = $webDriverModule; } @@ -95,10 +71,11 @@ public function __construct( /** * Take snapshot of DOM and send to https://percy.io * + * @throws \Codeception\Exception\ModuleException + * @throws \Codeception\Module\Percy\Exception\ConfigException * @throws \Codeception\Module\Percy\Exception\StorageException * @throws \JsonException * @throws \tr33m4n\CodeceptionModulePercyEnvironment\Exception\EnvironmentException - * @throws \Codeception\Module\Percy\Exception\ConfigException * @param string $name * @param array $snapshotConfig */ @@ -114,18 +91,18 @@ public function takeAPercySnapshot( // Add Percy CLI JS to page $this->webDriver->executeJS($this->configManagement->getPercyCliBrowserJs()); - /** @var string $domSnapshot */ - $domSnapshot = $this->webDriver->executeJS( + /** @var string $domString */ + $domString = $this->webDriver->executeJS( sprintf('return PercyDOM.serialize(%s)', $this->configManagement->getSerializeConfig()) ); - $this->requestManagement->addPayload( - Payload::from(array_merge($this->configManagement->getSnapshotConfig(), $snapshotConfig)) - ->withName($name) - ->withUrl($this->webDriver->webDriver->getCurrentURL()) - ->withDomSnapshot($this->createSnapshot->execute($domSnapshot)) - ->withClientInfo($this->environmentProvider->getClientInfo()) - ->withEnvironmentInfo($this->environmentProvider->getEnvironmentInfo()) + $this->snapshotManagement->createSnapshot( + $domString, + $name, + $this->webDriver->webDriver->getCurrentURL(), + $this->environmentProvider->getClientInfo(), + $this->environmentProvider->getEnvironmentInfo(), + array_merge($this->configManagement->getSnapshotConfig(), $snapshotConfig) ); } @@ -138,19 +115,17 @@ public function takeAPercySnapshot( */ public function _afterSuite(): void { - if (!$this->requestManagement->hasPayloads()) { + if ($this->configManagement->shouldCollectOnly()) { + $this->debugSection(Definitions::NAMESPACE, 'All snapshots collected!'); + return; } - $this->debugSection(self::NAMESPACE, 'Sending Percy snapshots..'); - try { - $this->requestManagement->sendRequest(); + $this->snapshotManagement->sendInstance(); } catch (Exception $exception) { $this->debugConnectionError($exception); } - - $this->debugSection(self::NAMESPACE, 'All snapshots sent!'); } /** @@ -164,7 +139,7 @@ public function _afterSuite(): void */ public function _failed(TestInterface $test, $fail): void { - $this->requestManagement->resetRequest(); + $this->snapshotManagement->resetInstance(); } /** @@ -176,7 +151,7 @@ public function _failed(TestInterface $test, $fail): void private function debugConnectionError(Exception $exception): void { $this->debugSection( - self::NAMESPACE, + Definitions::NAMESPACE, [$exception->getMessage(), $exception->getTraceAsString()] ); diff --git a/src/Codeception/Module/Percy/CleanSnapshots.php b/src/Codeception/Module/Percy/CleanSnapshots.php deleted file mode 100644 index 0ef9fef..0000000 --- a/src/Codeception/Module/Percy/CleanSnapshots.php +++ /dev/null @@ -1,35 +0,0 @@ -configManagement = $configManagement; - } - - /** - * Clean snapshot directory - */ - public function execute(): void - { - if (!$this->configManagement->shouldCleanSnapshotStorage()) { - return; - } - - foreach (glob(codecept_output_dir(sprintf(CreateSnapshot::OUTPUT_FILE_PATTERN, '*'))) ?: [] as $snapshotFile) { - unlink($snapshotFile); - } - } -} diff --git a/src/Codeception/Module/Percy/Command/ProcessSnapshots.php b/src/Codeception/Module/Percy/Command/ProcessSnapshots.php new file mode 100644 index 0000000..a5a9f93 --- /dev/null +++ b/src/Codeception/Module/Percy/Command/ProcessSnapshots.php @@ -0,0 +1,79 @@ +snapshotManagement = $serviceContainer->getSnapshotManagement(); + + parent::__construct($name); + } + + /** + * @inheritDoc + */ + public static function getCommandName(): string + { + return 'percy:process-snapshots'; + } + + /** + * Get default description + * + * @return string + */ + public static function getDefaultDescription(): string + { + return 'Process any snapshots that exist in the snapshot directory, then cleanup'; + } + + /** + * {@inheritdoc} + * + * Process snapshots + * + * @throws \Codeception\Module\Percy\Exception\AdapterException + * @throws \Codeception\Module\Percy\Exception\ConfigException + * @throws \Codeception\Module\Percy\Exception\StorageException + * @throws \JsonException + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + Debug::setOutput(new Output([])); + + $this->snapshotManagement->sendAll(); + $this->snapshotManagement->resetAll(); + + $io->success('Successfully processed snapshots'); + + return self::SUCCESS; + } +} diff --git a/src/Codeception/Module/Percy/ConfigManagement.php b/src/Codeception/Module/Percy/ConfigManagement.php index a7d2176..6a164e9 100644 --- a/src/Codeception/Module/Percy/ConfigManagement.php +++ b/src/Codeception/Module/Percy/ConfigManagement.php @@ -8,6 +8,8 @@ class ConfigManagement { + private Serializer $serializer; + /** * @var array */ @@ -16,11 +18,14 @@ class ConfigManagement /** * ConfigManagement constructor. * - * @param array $config + * @param \Codeception\Module\Percy\Serializer $serializer + * @param array $config */ public function __construct( + Serializer $serializer, array $config = [] ) { + $this->serializer = $serializer; $this->config = $config; } @@ -82,51 +87,51 @@ public function getPercyCliBrowserJs(): string } /** - * Get snapshot base URL + * Get snapshot server timeout * - * @throws \Codeception\Module\Percy\Exception\ConfigException - * @return string + * @return float|null */ - public function getSnapshotBaseUrl(): string + public function getSnapshotServerTimeout(): ?float { - /** @var string $snapshotBaseUrl */ - $snapshotBaseUrl = $this->get('snapshotBaseUrl'); - if (!filter_var($snapshotBaseUrl, FILTER_VALIDATE_URL)) { - throw new ConfigException('Snapshot base URL is not a valid URL'); + $snapshotServerTimeout = $this->get('snapshotServerTimeout'); + if (!is_numeric($snapshotServerTimeout)) { + return null; } - return $snapshotBaseUrl; + return (float) $snapshotServerTimeout; } /** - * Get snapshot path + * Get snapshot server port * * @throws \Codeception\Module\Percy\Exception\ConfigException - * @return string + * @return int */ - public function getSnapshotPath(): string + public function getSnapshotServerPort(): int { - $snapshotPath = $this->get('snapshotPath'); - if (!is_string($snapshotPath)) { - throw new ConfigException('Snapshot path is not a string'); + /** @var int $snapshotServerPort */ + $snapshotServerPort = $this->get('snapshotServerPort'); + if (!is_int($snapshotServerPort)) { + throw new ConfigException(sprintf('"%s" is an invalid port number', $snapshotServerPort)); } - return $snapshotPath; + return $snapshotServerPort; } /** - * Get snapshot server timeout + * Get snapshot server URI * - * @return float|null + * @throws \Codeception\Module\Percy\Exception\ConfigException + * @return string */ - public function getSnapshotServerTimeout(): ?float + public function getSnapshotServerUri(): string { - $snapshotServerTimeout = $this->get('snapshotServerTimeout'); - if (!is_numeric($snapshotServerTimeout)) { - return null; + $snapshotServerUri = sprintf('http://localhost:%s/percy/snapshot', $this->getSnapshotServerPort()); + if (!filter_var($snapshotServerUri, FILTER_VALIDATE_URL)) { + throw new ConfigException(sprintf('Snapshot URI "%s" is not valid', $snapshotServerUri)); } - return (float) $snapshotServerTimeout; + return $snapshotServerUri; } /** @@ -144,6 +149,21 @@ public function getSnapshotConfig(): array return $snapshotConfig; } + /** + * Get instance ID + * + * @return string|null + */ + public function getInstanceId(): ?string + { + $instanceId = $this->get('instanceId'); + if (!is_string($instanceId)) { + return null; + } + + return $instanceId; + } + /** * Get serialize config * @@ -152,7 +172,13 @@ public function getSnapshotConfig(): array */ public function getSerializeConfig(): string { - return json_encode($this->get('serializeConfig'), JSON_THROW_ON_ERROR); + /** @var array $serializedConfig */ + $serializedConfig = $this->get('serializeConfig'); + if (!is_array($serializedConfig)) { + return ''; + } + + return $this->serializer->serialize($serializedConfig); } /** @@ -175,6 +201,16 @@ public function shouldThrowOnAdapterError(): bool return (bool) $this->get('throwOnAdapterError'); } + /** + * Check if we should be collecting snapshots, rather than sending + * + * @return bool + */ + public function shouldCollectOnly(): bool + { + return (bool) $this->get('collectOnly'); + } + /** * Validate file path * diff --git a/src/Codeception/Module/Percy/CreateSnapshot.php b/src/Codeception/Module/Percy/CreateSnapshot.php deleted file mode 100644 index 6950c42..0000000 --- a/src/Codeception/Module/Percy/CreateSnapshot.php +++ /dev/null @@ -1,46 +0,0 @@ -toString())); - - $fileDirectory = dirname($filePath); - if (!file_exists($fileDirectory)) { - mkdir($fileDirectory, 0777, true); - } - - if (!is_writable($fileDirectory)) { - chmod($fileDirectory, 0777); - } - - $writeResults = file_put_contents($filePath, $domString); - if (!$writeResults) { - throw new StorageException('Something went wrong when writing the DOM string'); - } - - return Snapshot::create($filePath); - } -} diff --git a/src/Codeception/Module/Percy/Definitions.php b/src/Codeception/Module/Percy/Definitions.php new file mode 100644 index 0000000..3966cb5 --- /dev/null +++ b/src/Codeception/Module/Percy/Definitions.php @@ -0,0 +1,30 @@ + [ + 'enableJavaScript' => true + ], + 'snapshotConfig' => [ + 'widths' => [ + 375, + 1280 + ], + 'minHeight' => 1024 + ], + 'collectOnly' => false, + 'snapshotServerTimeout' => null, + 'snapshotServerPort' => 5338, + 'throwOnAdapterError' => false, + 'instanceId' => null + ]; +} diff --git a/src/Codeception/Module/Percy/Exception/AbstractException.php b/src/Codeception/Module/Percy/Exception/AbstractException.php index 26530aa..b295eb2 100644 --- a/src/Codeception/Module/Percy/Exception/AbstractException.php +++ b/src/Codeception/Module/Percy/Exception/AbstractException.php @@ -5,7 +5,7 @@ namespace Codeception\Module\Percy\Exception; use Codeception\Exception\ModuleException; -use Codeception\Module\Percy; +use Codeception\Module\Percy\Definitions; abstract class AbstractException extends ModuleException { @@ -16,6 +16,6 @@ abstract class AbstractException extends ModuleException */ public function __construct(string $message) { - parent::__construct(Percy::NAMESPACE, $message); + parent::__construct(Definitions::NAMESPACE, $message); } } diff --git a/src/Codeception/Module/Percy/Exception/ContainerException.php b/src/Codeception/Module/Percy/Exception/ContainerException.php new file mode 100644 index 0000000..35e160e --- /dev/null +++ b/src/Codeception/Module/Percy/Exception/ContainerException.php @@ -0,0 +1,10 @@ +resource = curl_init(); - $this->setBaseUrl($baseUrl); $this->setDefaults(); } - /** - * @inheritDoc - */ - public function setBaseUrl(string $baseUrl): AdapterInterface - { - $this->baseUrl = $baseUrl; - - return $this; - } - /** * {@inheritdoc} * * @throws \Codeception\Module\Percy\Exception\AdapterException - * @param string $path * @return \Codeception\Module\Percy\Exchange\Adapter\AdapterInterface */ - public function setPath(string $path): AdapterInterface + public function setUri(string $uri): AdapterInterface { - curl_setopt($this->getResource(), CURLOPT_URL, rtrim($this->baseUrl, '/') . '/' . $path); + curl_setopt($this->getResource(), CURLOPT_URL, $uri); return $this; } diff --git a/src/Codeception/Module/Percy/Exchange/Client.php b/src/Codeception/Module/Percy/Exchange/Client.php index bed5d44..dcedf24 100644 --- a/src/Codeception/Module/Percy/Exchange/Client.php +++ b/src/Codeception/Module/Percy/Exchange/Client.php @@ -5,30 +5,43 @@ namespace Codeception\Module\Percy\Exchange; use Codeception\Module\Percy\Exchange\Adapter\AdapterInterface; +use Codeception\Module\Percy\Serializer; +use Codeception\Module\Percy\Snapshot; class Client implements ClientInterface { private AdapterInterface $adapter; + private Serializer $serializer; + /** * Client constructor. * * @param \Codeception\Module\Percy\Exchange\Adapter\AdapterInterface $adapter + * @param \Codeception\Module\Percy\Serializer $serializer */ public function __construct( - AdapterInterface $adapter + AdapterInterface $adapter, + Serializer $serializer ) { $this->adapter = $adapter; + $this->serializer = $serializer; } /** - * @inheritDoc + * {@inheritdoc} + * + * @throws \Codeception\Module\Percy\Exception\AdapterException + * @throws \JsonException + * @param string $uri + * @param \Codeception\Module\Percy\Snapshot $snapshot + * @return string */ - public function post(string $path, Payload $payload = null): string + public function post(string $uri, Snapshot $snapshot): string { - $payloadAsString = (string) $payload; + $payloadAsString = $this->serializer->serialize($snapshot); - return $this->adapter->setPath($path) + return $this->adapter->setUri($uri) ->setPayload($payloadAsString) ->setHeaders([ 'Content-Type: application/json', diff --git a/src/Codeception/Module/Percy/Exchange/ClientInterface.php b/src/Codeception/Module/Percy/Exchange/ClientInterface.php index 5cfc004..ac98100 100644 --- a/src/Codeception/Module/Percy/Exchange/ClientInterface.php +++ b/src/Codeception/Module/Percy/Exchange/ClientInterface.php @@ -4,15 +4,17 @@ namespace Codeception\Module\Percy\Exchange; +use Codeception\Module\Percy\Snapshot; + interface ClientInterface { /** * Post * * @throws \Codeception\Module\Percy\Exception\AdapterException - * @param string $path - * @param \Codeception\Module\Percy\Exchange\Payload|null $payload + * @param string $uri + * @param \Codeception\Module\Percy\Snapshot $snapshot * @return string */ - public function post(string $path, Payload $payload = null): string; + public function post(string $uri, Snapshot $snapshot): string; } diff --git a/src/Codeception/Module/Percy/Exchange/Payload.php b/src/Codeception/Module/Percy/Exchange/Payload.php deleted file mode 100644 index 25d448a..0000000 --- a/src/Codeception/Module/Percy/Exchange/Payload.php +++ /dev/null @@ -1,186 +0,0 @@ - - */ - private array $config = []; - - /** - * Payload constructor. - */ - private function __construct() - { - // - } - - /** - * From array - * - * @param array $payloadArray - * @return \Codeception\Module\Percy\Exchange\Payload - */ - public static function from(array $payloadArray): Payload - { - return array_reduce( - array_keys($payloadArray), - static function (Payload $payload, string $configKey) use ($payloadArray): Payload { - if (!in_array($configKey, self::PUBLIC_KEYS)) { - throw new InvalidArgumentException( - sprintf('"%s" cannot be set through config', $configKey) - ); - } - - return self::withValue($payload, $configKey, $payloadArray[$configKey]); - }, - new self() - ); - } - - /** - * With name - * - * @param string $name - * @return \Codeception\Module\Percy\Exchange\Payload - */ - public function withName(string $name): Payload - { - return self::withValue(clone $this, self::NAME, $name); - } - - /** - * With URL - * - * @param string $url - * @return \Codeception\Module\Percy\Exchange\Payload - */ - public function withUrl(string $url): Payload - { - return self::withValue(clone $this, self::URL, $url); - } - - /** - * With min height - * - * @param int|null $minHeight - * @return \Codeception\Module\Percy\Exchange\Payload - */ - public function withMinHeight(?int $minHeight): Payload - { - return self::withValue(clone $this, self::MIN_HEIGHT, $minHeight); - } - - /** - * With DOM snapshot - * - * @param \Codeception\Module\Percy\Snapshot $domSnapshot - * @return \Codeception\Module\Percy\Exchange\Payload - */ - public function withDomSnapshot(Snapshot $domSnapshot): Payload - { - return self::withValue(clone $this, self::DOM_SNAPSHOT, $domSnapshot); - } - - /** - * With client info - * - * @param string $clientInfo - * @return \Codeception\Module\Percy\Exchange\Payload - */ - public function withClientInfo(string $clientInfo): Payload - { - return self::withValue(clone $this, self::CLIENT_INFO, $clientInfo); - } - - /** - * With environment info - * - * @param string $environmentInfo - * @return \Codeception\Module\Percy\Exchange\Payload - */ - public function withEnvironmentInfo(string $environmentInfo): Payload - { - return self::withValue(clone $this, self::ENVIRONMENT_INFO, $environmentInfo); - } - - /** - * With value - * - * @throws \InvalidArgumentException - * @param \Codeception\Module\Percy\Exchange\Payload $payload - * @param string $key - * @param mixed $value - * @return \Codeception\Module\Percy\Exchange\Payload - */ - private static function withValue(Payload $payload, string $key, $value): Payload - { - $payload->config[$key] = $value; - - return $payload; - } - - /** - * Get name - * - * @return string - */ - public function getName(): string - { - if (!array_key_exists(self::NAME, $this->config)) { - return ''; - } - - if (!is_string($this->config[self::NAME])) { - return ''; - } - - return $this->config[self::NAME]; - } - - /** - * Encode config as JSON when casting to string - * - * @throws \JsonException - * @return string - */ - public function __toString(): string - { - return json_encode($this->config, JSON_THROW_ON_ERROR) ?: ''; - } -} diff --git a/src/Codeception/Module/Percy/ProcessManagement.php b/src/Codeception/Module/Percy/ProcessManagement.php index d472856..2a02cf6 100644 --- a/src/Codeception/Module/Percy/ProcessManagement.php +++ b/src/Codeception/Module/Percy/ProcessManagement.php @@ -34,14 +34,21 @@ public function __construct( */ public function startPercySnapshotServer(): void { + if ($this->process instanceof Process && $this->process->isRunning()) { + return; + } + $this->process = new Process([ - self::resolveNodePath(), + $this->resolveNodePath(), $this->configManagement->getPercyCliExecutablePath(), - 'exec:start' + 'exec:start', + '--port', + $this->configManagement->getSnapshotServerPort() ]); - $this->process->setTimeout($this->configManagement->getSnapshotServerTimeout()); - $this->process->start(); + $this->process + ->setTimeout($this->configManagement->getSnapshotServerTimeout()) + ->start(); // Wait until server is ready $this->process->waitUntil(fn (string $type, string $output): bool => $this->hasServerStarted($output)); diff --git a/src/Codeception/Module/Percy/RequestManagement.php b/src/Codeception/Module/Percy/RequestManagement.php deleted file mode 100644 index 6396dfa..0000000 --- a/src/Codeception/Module/Percy/RequestManagement.php +++ /dev/null @@ -1,101 +0,0 @@ -configManagement = $configManagement; - $this->cleanSnapshots = $cleanSnapshots; - $this->processManagement = $processManagement; - $this->client = $client; - } - - /** - * Add a payload - * - * @param \Codeception\Module\Percy\Exchange\Payload $payload - * @return \Codeception\Module\Percy\RequestManagement - */ - public function addPayload(Payload $payload): RequestManagement - { - $this->payloads[] = $payload; - - return $this; - } - - /** - * Check if request has payloads - * - * @return bool - */ - public function hasPayloads(): bool - { - return $this->payloads !== []; - } - - /** - * Send payloads to Percy - * - * @throws \Codeception\Module\Percy\Exception\AdapterException - * @throws \Codeception\Module\Percy\Exception\ConfigException - */ - public function sendRequest(): void - { - if (!$this->hasPayloads()) { - return; - } - - $this->processManagement->startPercySnapshotServer(); - - foreach ($this->payloads as $payload) { - codecept_debug(sprintf('[Percy] Sending snapshot "%s"', $payload->getName())); - - $this->client->post($this->configManagement->getSnapshotPath(), $payload); - } - - $this->processManagement->stopPercySnapshotServer(); - - $this->resetRequest(); - } - - /** - * Reset payloads - */ - public function resetRequest(): void - { - $this->payloads = []; - $this->cleanSnapshots->execute(); - } -} diff --git a/src/Codeception/Module/Percy/Serializer.php b/src/Codeception/Module/Percy/Serializer.php new file mode 100644 index 0000000..7c4e20b --- /dev/null +++ b/src/Codeception/Module/Percy/Serializer.php @@ -0,0 +1,32 @@ +|\Codeception\Module\Percy\Snapshot $data + * @return string + */ + public function serialize($data): string + { + return json_encode($data, JSON_THROW_ON_ERROR); + } + + /** + * Unserialize data + * + * @throws \JsonException + * @param string $data + * @return array + */ + public function unserialize(string $data): array + { + return (array) json_decode($data, true, 512, JSON_THROW_ON_ERROR); + } +} diff --git a/src/Codeception/Module/Percy/ServiceContainer.php b/src/Codeception/Module/Percy/ServiceContainer.php index 4d73d3d..ae5d180 100644 --- a/src/Codeception/Module/Percy/ServiceContainer.php +++ b/src/Codeception/Module/Percy/ServiceContainer.php @@ -4,7 +4,7 @@ namespace Codeception\Module\Percy; -use Codeception\Module\Percy; +use Codeception\Module\Percy\Exception\ContainerException; use Codeception\Module\Percy\Exchange\Adapter\AdapterInterface; use Codeception\Module\Percy\Exchange\Adapter\CurlAdapter; use Codeception\Module\Percy\Exchange\Client; @@ -26,7 +26,7 @@ final class ServiceContainer { private ServiceFactory $serviceFactory; - private WebDriver $webDriver; + private ?WebDriver $webDriver; /** * @var array @@ -41,11 +41,11 @@ final class ServiceContainer /** * ServiceContainer constructor. * - * @param \Codeception\Module\WebDriver $webDriver - * @param array $moduleConfig + * @param \Codeception\Module\WebDriver|null $webDriver + * @param array $moduleConfig */ public function __construct( - WebDriver $webDriver, + ?WebDriver $webDriver, array $moduleConfig = [] ) { $this->serviceFactory = new ServiceFactory(); @@ -172,10 +172,15 @@ public function getPercyEnvironment(): PercyEnvironment /** * Get environment provider * + * @throws \Codeception\Module\Percy\Exception\ContainerException * @return \tr33m4n\CodeceptionModulePercyEnvironment\EnvironmentProviderInterface */ public function getEnvironmentProvider(): EnvironmentProviderInterface { + if (!$this->webDriver instanceof WebDriver) { + throw new ContainerException('Web driver has not been configured'); + } + return $this->resolveService( EnvironmentProvider::class, [ @@ -183,39 +188,29 @@ public function getEnvironmentProvider(): EnvironmentProviderInterface $this->getGitEnvironment(), $this->getPercyEnvironment(), $this->webDriver, - Percy::PACKAGE_NAME + Definitions::PACKAGE_NAME ] ); } /** - * Get config management - * - * @return \Codeception\Module\Percy\ConfigManagement - */ - public function getConfigManagement(): ConfigManagement - { - return $this->resolveService(ConfigManagement::class, [$this->moduleConfig]); - } - - /** - * Get create snapshot + * Get serializer * - * @return \Codeception\Module\Percy\CreateSnapshot + * @return \Codeception\Module\Percy\Serializer */ - public function getCreateSnapshot(): CreateSnapshot + public function getSerializer(): Serializer { - return $this->resolveService(CreateSnapshot::class); + return $this->resolveService(Serializer::class); } /** - * Get clean snapshots + * Get config management * - * @return \Codeception\Module\Percy\CleanSnapshots + * @return \Codeception\Module\Percy\ConfigManagement */ - public function getCleanSnapshots(): CleanSnapshots + public function getConfigManagement(): ConfigManagement { - return $this->resolveService(CleanSnapshots::class, [$this->getConfigManagement()]); + return $this->resolveService(ConfigManagement::class, [$this->getSerializer(), $this->moduleConfig]); } /** @@ -231,38 +226,48 @@ public function getProcessManagement(): ProcessManagement /** * Get adapter * - * @throws \Codeception\Module\Percy\Exception\ConfigException * @return \Codeception\Module\Percy\Exchange\Adapter\AdapterInterface */ public function getAdapter(): AdapterInterface { - return $this->resolveService(CurlAdapter::class, [$this->getConfigManagement()->getSnapshotBaseUrl()]); + return $this->resolveService(CurlAdapter::class); } /** * Get client * - * @throws \Codeception\Module\Percy\Exception\ConfigException * @return \Codeception\Module\Percy\Exchange\ClientInterface */ public function getClient(): ClientInterface { - return $this->resolveService(Client::class, [$this->getAdapter()]); + return $this->resolveService(Client::class, [$this->getAdapter(), $this->getSerializer()]); + } + + /** + * Get snapshot repository + * + * @return \Codeception\Module\Percy\SnapshotRepository + */ + public function getSnapshotRepository(): SnapshotRepository + { + return $this->resolveService( + SnapshotRepository::class, + [$this->getSerializer(), $this->getConfigManagement()->getInstanceId()] + ); } /** - * Get request management + * Get snapshot management * - * @throws \Codeception\Module\Percy\Exception\ConfigException - * @return \Codeception\Module\Percy\RequestManagement + * @return \Codeception\Module\Percy\SnapshotManagement */ - public function getRequestManagement(): RequestManagement + public function getSnapshotManagement(): SnapshotManagement { return $this->resolveService( - RequestManagement::class, + SnapshotManagement::class, [ $this->getConfigManagement(), - $this->getCleanSnapshots(), + $this->getSnapshotRepository(), $this->getProcessManagement(), $this->getClient() ] diff --git a/src/Codeception/Module/Percy/Snapshot.php b/src/Codeception/Module/Percy/Snapshot.php index 2ec3dee..8ae34b8 100644 --- a/src/Codeception/Module/Percy/Snapshot.php +++ b/src/Codeception/Module/Percy/Snapshot.php @@ -4,11 +4,64 @@ namespace Codeception\Module\Percy; +use InvalidArgumentException; use JsonSerializable; class Snapshot implements JsonSerializable { - private string $filePath; + public const NAME = 'name'; + + public const URL = 'url'; + + public const PERCY_CSS = 'percyCSS'; + + public const MIN_HEIGHT = 'minHeight'; + + public const DOM_SNAPSHOT = 'domSnapshot'; + + public const CLIENT_INFO = 'clientInfo'; + + public const ENABLE_JAVASCRIPT = 'enableJavaScript'; + + public const ENVIRONMENT_INFO = 'environmentInfo'; + + public const WIDTHS = 'widths'; + + /** + * Array of keys that are optional + */ + public const OPTIONAL_KEYS = [ + self::PERCY_CSS, + self::MIN_HEIGHT, + self::ENABLE_JAVASCRIPT, + self::WIDTHS + ]; + + /** + * Array of required keys + */ + public const REQUIRED_KEYS = [ + self::DOM_SNAPSHOT, + self::NAME, + self::URL, + self::CLIENT_INFO, + self::ENVIRONMENT_INFO + ]; + + private string $name; + + private string $domSnapshot; + + private string $url; + + private string $clientInfo; + + private string $environmentInfo; + + /** + * @var array + */ + private array $config = []; /** * Snapshot constructor. @@ -21,32 +74,147 @@ private function __construct() /** * Create from file path * - * @param string $filePath + * @param string $domSnapshot + * @param string $name + * @param string $url + * @param string $clientInfo + * @param string $environmentInfo + * @param array $additionalConfig * @return \Codeception\Module\Percy\Snapshot */ - public static function create(string $filePath): Snapshot - { + public static function create( + string $domSnapshot, + string $name, + string $url, + string $clientInfo, + string $environmentInfo, + array $additionalConfig = [] + ): Snapshot { $snapshot = new self(); - $snapshot->filePath = $filePath; + $snapshot->domSnapshot = $domSnapshot; + $snapshot->name = $name; + $snapshot->url = $url; + $snapshot->clientInfo = $clientInfo; + $snapshot->environmentInfo = $environmentInfo; + + return array_reduce( + array_keys($additionalConfig), + static function (Snapshot $snapshot, string $configKey) use ($additionalConfig): Snapshot { + if (!in_array($configKey, self::OPTIONAL_KEYS)) { + throw new InvalidArgumentException( + sprintf('"%s" cannot be set through config', $configKey) + ); + } + + return $snapshot->withConfigValue($configKey, $additionalConfig[$configKey]); + }, + $snapshot + ); + } + + /** + * Hydrate snapshot from snapshot data + * + * @param array $snapshotData + * @return \Codeception\Module\Percy\Snapshot + */ + public static function hydrate(array $snapshotData): Snapshot + { + if (count(array_intersect_key($snapshotData, array_flip(self::REQUIRED_KEYS))) !== count(self::REQUIRED_KEYS)) { + throw new InvalidArgumentException('Missing required snapshot fields'); + } + + return self::create( + $snapshotData[self::DOM_SNAPSHOT], + $snapshotData[self::NAME], + $snapshotData[self::URL], + $snapshotData[self::CLIENT_INFO], + $snapshotData[self::ENVIRONMENT_INFO], + array_diff_key($snapshotData, array_flip(self::REQUIRED_KEYS)) + ); + } + + /** + * With value + * + * @param string $key + * @param mixed $value + * @return \Codeception\Module\Percy\Snapshot + */ + public function withConfigValue(string $key, $value): Snapshot + { + $snapshot = clone $this; + $snapshot->config[$key] = $value; return $snapshot; } /** - * Get file path + * Get name + * + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * Get URL + * + * @return string + */ + public function getUrl(): string + { + return $this->url; + } + + /** + * Get client info + * + * @return string + */ + public function getClientInfo(): string + { + return $this->clientInfo; + } + + /** + * Get environment info * * @return string */ - public function getFilePath(): string + public function getEnvironmentInfo(): string { - return $this->filePath; + return $this->environmentInfo; } /** - * @inheritDoc + * Get DOM snapshot + * + * @return string + */ + public function getDomSnapshot(): string + { + return $this->domSnapshot; + } + + /** + * {@inheritdoc} + * + * @return array */ - public function jsonSerialize(): string + public function jsonSerialize(): array { - return file_get_contents($this->getFilePath()) ?: ''; + return array_merge( + [ + self::NAME => $this->getName(), + self::URL => $this->getUrl(), + self::CLIENT_INFO => $this->getClientInfo(), + self::ENVIRONMENT_INFO => $this->getEnvironmentInfo(), + self::DOM_SNAPSHOT => $this->getDomSnapshot() + ], + $this->config + ); } } diff --git a/src/Codeception/Module/Percy/SnapshotManagement.php b/src/Codeception/Module/Percy/SnapshotManagement.php new file mode 100644 index 0000000..dcd042b --- /dev/null +++ b/src/Codeception/Module/Percy/SnapshotManagement.php @@ -0,0 +1,145 @@ +configManagement = $configManagement; + $this->snapshotRepository = $snapshotRepository; + $this->processManagement = $processManagement; + $this->client = $client; + } + + /** + * Create snapshot + * + * @throws \Codeception\Module\Percy\Exception\StorageException + * @throws \JsonException + * @param string $domString + * @param string $name + * @param string $currentUrl + * @param string $clientInfo + * @param string $environmentInfo + * @param array $additionalConfig + */ + public function createSnapshot( + string $domString, + string $name, + string $currentUrl, + string $clientInfo, + string $environmentInfo, + array $additionalConfig = [] + ): void { + $this->snapshotRepository->save( + Snapshot::create( + $domString, + $name, + $currentUrl, + $clientInfo, + $environmentInfo, + $additionalConfig + ) + ); + } + + /** + * Send all snapshots + * + * @throws \Codeception\Module\Percy\Exception\AdapterException + * @throws \Codeception\Module\Percy\Exception\ConfigException + * @throws \Codeception\Module\Percy\Exception\StorageException + * @throws \JsonException + */ + public function sendAll(): void + { + $this->sendInstance('*'); + } + + /** + * Send instance snapshots + * + * @throws \Codeception\Module\Percy\Exception\AdapterException + * @throws \Codeception\Module\Percy\Exception\ConfigException + * @throws \Codeception\Module\Percy\Exception\StorageException + * @throws \JsonException + * @param string|null $instanceId + */ + public function sendInstance(string $instanceId = null): void + { + // Passing `*` will load all snapshots from all runs, not just the current one + $snapshots = $this->snapshotRepository->loadAll($instanceId); + if ([] === $snapshots) { + $this->debug('No snapshots to send!'); + + return; + } + + $this->debug(sprintf('Sending %s Percy snapshots...', count($snapshots))); + + $this->processManagement->startPercySnapshotServer(); + + foreach ($snapshots as $snapshot) { + $this->debug(sprintf('Sending snapshot "%s"', $snapshot->getName())); + + $this->client->post($this->configManagement->getSnapshotServerUri(), $snapshot); + } + + $this->processManagement->stopPercySnapshotServer(); + + $this->debug('All snapshots sent!'); + } + + /** + * Reset all + */ + public function resetAll(): void + { + $this->resetInstance('*'); + } + + /** + * Reset instance + * + * @param string|null $instanceId + */ + public function resetInstance(string $instanceId = null): void + { + $this->snapshotRepository->deleteAll($instanceId); + } + + /** + * Output debug message + * + * @param string $message + */ + private function debug(string $message): void + { + codecept_debug(sprintf('[%s] %s', Definitions::NAMESPACE, $message)); + } +} diff --git a/src/Codeception/Module/Percy/SnapshotRepository.php b/src/Codeception/Module/Percy/SnapshotRepository.php new file mode 100644 index 0000000..b5de603 --- /dev/null +++ b/src/Codeception/Module/Percy/SnapshotRepository.php @@ -0,0 +1,133 @@ +serializer = $serializer; + // Ensure we're only managing snapshots created by this test run by prepending with an "instance ID" + $this->instanceId = $instanceId ?? (string) Uuid::uuid4(); + } + + /** + * Save snapshot + * + * @throws \Codeception\Module\Percy\Exception\StorageException + * @throws \JsonException + * @param \Codeception\Module\Percy\Snapshot $snapshot + * @return void + */ + public function save(Snapshot $snapshot): void + { + if (!function_exists('codecept_output_dir')) { + throw new StorageException('`codecept_output_dir` function is not available!'); + } + + $filePath = codecept_output_dir( + sprintf(self::STORAGE_FILE_PATTERN, $this->instanceId, (string) Uuid::uuid4()) + ); + + $fileDirectory = dirname($filePath); + if (!file_exists($fileDirectory)) { + mkdir($fileDirectory, 0777, true); + } + + if (!is_writable($fileDirectory)) { + chmod($fileDirectory, 0777); + } + + $writeResults = file_put_contents($filePath, $this->serializer->serialize($snapshot)); + if (!$writeResults) { + throw new StorageException('Something went wrong when writing the DOM string'); + } + } + + /** + * Load snapshot from file + * + * @throws \Codeception\Module\Percy\Exception\StorageException + * @throws \JsonException + * @param string $snapshotFilePath + * @return \Codeception\Module\Percy\Snapshot + */ + public function load(string $snapshotFilePath): Snapshot + { + $snapshotFileContents = file_get_contents($snapshotFilePath); + if (!$snapshotFileContents) { + throw new StorageException(sprintf('Unable to load the snapshot file "%s"', $snapshotFilePath)); + } + + /** @var array $decodedSnapshotFileContents */ + $decodedSnapshotFileContents = $this->serializer->unserialize($snapshotFileContents); + + return Snapshot::hydrate($decodedSnapshotFileContents); + } + + /** + * Load all snapshots + * + * @throws \Codeception\Module\Percy\Exception\StorageException + * @throws \JsonException + * @param string|null $instanceId + * @return \Codeception\Module\Percy\Snapshot[] + */ + public function loadAll(string $instanceId = null): array + { + return array_map( + fn (string $snapshotFile): Snapshot => $this->load($snapshotFile), + $this->getSnapshotFilePaths($instanceId) + ); + } + + /** + * Delete all snapshots + * + * @param string|null $instanceId + */ + public function deleteAll(string $instanceId = null): void + { + foreach ($this->getSnapshotFilePaths($instanceId) as $snapshotFile) { + unlink($snapshotFile); + } + } + + /** + * Get snapshot file paths + * + * @param string|null $instanceId + * @return string[] + */ + private function getSnapshotFilePaths(string $instanceId = null): array + { + return glob( + codecept_output_dir( + sprintf( + self::STORAGE_FILE_PATTERN, + $instanceId ?? $this->instanceId, + '*' + ) + ) + ) ?: []; + } +} diff --git a/tests/unit/Codeception/Module/Percy/Test/.gitkeep b/tests/unit/Codeception/Module/Percy/Test/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/Codeception/Module/Percy/Test/Exchange/PayloadTest.php b/tests/unit/Codeception/Module/Percy/Test/Exchange/PayloadTest.php deleted file mode 100644 index 45452b8..0000000 --- a/tests/unit/Codeception/Module/Percy/Test/Exchange/PayloadTest.php +++ /dev/null @@ -1,36 +0,0 @@ -expectException(InvalidArgumentException::class); - - Payload::from(['invalid' => 'key']); - } - - /** - * Test that the payload can be cast to a JSON string - */ - public function testCanBeCastToJson(): void - { - $this->assertEquals( - '{"enableJavaScript":true,"name":"Test","url":"https:\/\/example.com"}', - (string) Payload::from([ - Payload::ENABLE_JAVASCRIPT => true - ])->withName('Test')->withUrl('https://example.com') - ); - } -}