From f8ca6abdc31d86aededbc23ce7b54819cd565e98 Mon Sep 17 00:00:00 2001 From: Ted Bowman <41201-tedbow@users.noreply.drupalcode.org> Date: Thu, 6 Apr 2023 16:29:38 +0000 Subject: [PATCH] Issue #3337667 by omkar.podey, phenaproxima, tedbow, Wim Leers: Allow JsonProcessOutputCallback and other Composer runner callbacks to gracefully handle deprecated command options --- package_manager/package_manager.services.yml | 6 + package_manager/src/ComposerInspector.php | 74 +++++----- .../src/JsonProcessOutputCallback.php | 73 --------- package_manager/src/ProcessOutputCallback.php | 113 ++++++++++++++ .../src/Kernel/ComposerInspectorTest.php | 8 +- .../Kernel/PackageManagerKernelTestBase.php | 29 ++++ .../tests/src/Kernel/ProjectInfoTest.php | 11 +- .../Kernel/SupportedReleaseValidatorTest.php | 3 + .../Unit/JsonProcessOutputCallbackTest.php | 68 --------- .../src/Unit/ProcessOutputCallbackTest.php | 138 ++++++++++++++++++ 10 files changed, 330 insertions(+), 193 deletions(-) delete mode 100644 package_manager/src/JsonProcessOutputCallback.php create mode 100644 package_manager/src/ProcessOutputCallback.php delete mode 100644 package_manager/tests/src/Unit/JsonProcessOutputCallbackTest.php create mode 100644 package_manager/tests/src/Unit/ProcessOutputCallbackTest.php diff --git a/package_manager/package_manager.services.yml b/package_manager/package_manager.services.yml index dc1a3c984d..791172e209 100644 --- a/package_manager/package_manager.services.yml +++ b/package_manager/package_manager.services.yml @@ -22,6 +22,10 @@ services: alias: 'Drupal\package_manager\ProcessFactory' PhpTuf\ComposerStager\Domain\Service\FileSyncer\FileSyncerInterface: factory: ['@Drupal\package_manager\FileSyncerFactory', 'create'] + logger.channel.package_manager: + parent: logger.channel_base + arguments: + - 'package_manager' # Services provided to Drupal by Package Manager. package_manager.beginner: @@ -51,6 +55,8 @@ services: - { name: event_subscriber } package_manager.composer_inspector: class: Drupal\package_manager\ComposerInspector + calls: + - [setLogger, ['@logger.channel.package_manager']] Drupal\package_manager\ComposerInspector: '@package_manager.composer_inspector' # Validators. diff --git a/package_manager/src/ComposerInspector.php b/package_manager/src/ComposerInspector.php index 3a4a6b6b31..602e871645 100644 --- a/package_manager/src/ComposerInspector.php +++ b/package_manager/src/ComposerInspector.php @@ -10,9 +10,12 @@ use Drupal\package_manager\Exception\ComposerNotReadyException; use PhpTuf\ComposerStager\Domain\Exception\PreconditionException; use PhpTuf\ComposerStager\Domain\Exception\RuntimeException; use PhpTuf\ComposerStager\Domain\Service\Precondition\ComposerIsAvailableInterface; -use PhpTuf\ComposerStager\Domain\Service\ProcessOutputCallback\ProcessOutputCallbackInterface; use PhpTuf\ComposerStager\Domain\Service\ProcessRunner\ComposerRunnerInterface; use PhpTuf\ComposerStager\Infrastructure\Factory\Path\PathFactoryInterface; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; /** * Defines a class to get information from Composer. @@ -23,16 +26,19 @@ use PhpTuf\ComposerStager\Infrastructure\Factory\Path\PathFactoryInterface; * - read project & package configuration: getConfig() (`composer config`) * - read root package info: getRootPackageInfo() (`composer show --self`) */ -class ComposerInspector { +class ComposerInspector implements LoggerAwareInterface { + use LoggerAwareTrait { + setLogger as traitSetLogger; + } use StringTranslationTrait; /** - * The JSON process output callback. + * The process output callback. * - * @var \Drupal\package_manager\JsonProcessOutputCallback + * @var \Drupal\package_manager\ProcessOutputCallback */ - private JsonProcessOutputCallback $jsonCallback; + private ProcessOutputCallback $processCallback; /** * Statically cached installed package lists, keyed by directory. @@ -69,7 +75,16 @@ class ComposerInspector { * The path factory service from Composer Stager. */ public function __construct(private ComposerRunnerInterface $runner, private ComposerIsAvailableInterface $composerIsAvailable, private PathFactoryInterface $pathFactory) { - $this->jsonCallback = new JsonProcessOutputCallback(); + $this->processCallback = new ProcessOutputCallback(); + $this->setLogger(new NullLogger()); + } + + /** + * {@inheritdoc} + */ + public function setLogger(LoggerInterface $logger): void { + $this->traitSetLogger($logger); + $this->processCallback->setLogger($logger); } /** @@ -237,32 +252,8 @@ class ComposerInspector { $this->validateProject($context); $command[] = "--working-dir={$context}"; } - - // For whatever reason, PHPCS thinks that $output is not used, even though - // it very clearly *is*. So, shut PHPCS up for the duration of this method. - // phpcs:disable DrupalPractice.CodeAnalysis.VariableAnalysis.UnusedVariable - $callback = new class () implements ProcessOutputCallbackInterface { - - /** - * The command output. - * - * @var string - */ - public string $output = ''; - - /** - * {@inheritdoc} - */ - public function __invoke(string $type, string $buffer): void { - if ($type === ProcessOutputCallbackInterface::OUT) { - $this->output .= trim($buffer); - } - } - - }; - // phpcs:enable try { - $this->runner->run($command, $callback); + $this->runner->run($command, $this->processCallback->reset()); } catch (RuntimeException $e) { // Assume any error from `composer config` is about an undefined key-value @@ -281,7 +272,8 @@ class ComposerInspector { throw $e; } } - return $callback->output; + $output = $this->processCallback->getOutput(); + return isset($output) ? trim($output) : $output; } /** @@ -291,11 +283,11 @@ class ComposerInspector { * The Composer version. * * @throws \UnexpectedValueException - * Thrown if the expect data format is not found. + * Thrown if the Composer version cannot be determined. */ private function getVersion(): string { - $this->runner->run(['--format=json'], $this->jsonCallback); - $data = $this->jsonCallback->getOutputData(); + $this->runner->run(['--format=json'], $this->processCallback->reset()); + $data = $this->processCallback->parseJsonOutput(); if (isset($data['application']['name']) && isset($data['application']['version']) && $data['application']['name'] === 'Composer' @@ -401,8 +393,8 @@ class ComposerInspector { public function getRootPackageInfo(string $working_dir): array { $this->validate($working_dir); - $this->runner->run(['show', '--self', '--format=json', "--working-dir={$working_dir}"], $this->jsonCallback); - return $this->jsonCallback->getOutputData(); + $this->runner->run(['show', '--self', '--format=json', "--working-dir={$working_dir}"], $this->processCallback->reset()); + return $this->processCallback->parseJsonOutput(); } /** @@ -424,8 +416,8 @@ class ComposerInspector { // about the installed packages. So, to work around this maddening quirk, we // call `composer show` once without the --path option, and once with it, // then merge the results together. - $this->runner->run($options, $this->jsonCallback); - $output = $this->jsonCallback->getOutputData(); + $this->runner->run($options, $this->processCallback->reset()); + $output = $this->processCallback->parseJsonOutput(); // $output['installed'] will not be set if no packages are installed. if (isset($output['installed'])) { foreach ($output['installed'] as $installed_package) { @@ -433,8 +425,8 @@ class ComposerInspector { } $options[] = '--path'; - $this->runner->run($options, $this->jsonCallback); - $output = $this->jsonCallback->getOutputData(); + $this->runner->run($options, $this->processCallback->reset()); + $output = $this->processCallback->parseJsonOutput(); foreach ($output['installed'] as $installed_package) { $data[$installed_package['name']]['path'] = $installed_package['path']; } diff --git a/package_manager/src/JsonProcessOutputCallback.php b/package_manager/src/JsonProcessOutputCallback.php deleted file mode 100644 index ed90ddae41..0000000000 --- a/package_manager/src/JsonProcessOutputCallback.php +++ /dev/null @@ -1,73 +0,0 @@ -<?php - -declare(strict_types = 1); - -namespace Drupal\package_manager; - -use Drupal\Component\Serialization\Json; -use PhpTuf\ComposerStager\Domain\Service\ProcessOutputCallback\ProcessOutputCallbackInterface; - -/** - * A process callback for handling output in the JSON format. - * - * @internal - * This is an internal part of Package Manager and may be changed or removed - * at any time without warning. External code should not interact with this - * class. - */ -final class JsonProcessOutputCallback implements ProcessOutputCallbackInterface { - - /** - * The output buffer. - * - * @var string - */ - private string $outBuffer = ''; - - /** - * The error buffer. - * - * @var string - */ - private string $errorBuffer = ''; - - /** - * {@inheritdoc} - */ - public function __invoke(string $type, string $buffer): void { - // @todo Support self::ERR output in - // https://www.drupal.org/project/automatic_updates/issues/3337504. - if ($type === self::OUT) { - $this->outBuffer .= $buffer; - return; - } - elseif ($type === self::ERR) { - $this->errorBuffer .= $buffer; - return; - } - throw new \InvalidArgumentException("Unsupported output type: '$type'"); - } - - /** - * Gets the output data. - * - * @return mixed|null - * The output data or NULL if there was an exception. - * - * @throws \Exception - * Thrown if error buffer was not empty. - */ - public function getOutputData() { - $error = $this->errorBuffer; - $out = $this->outBuffer; - $this->errorBuffer = ''; - $this->outBuffer = ''; - if ($error !== '') { - // @todo Handle deprecations messages in the error output in - // https://drupal.org/i/3337667. - throw new \Exception($error); - } - return Json::decode($out); - } - -} diff --git a/package_manager/src/ProcessOutputCallback.php b/package_manager/src/ProcessOutputCallback.php new file mode 100644 index 0000000000..f5896c94b4 --- /dev/null +++ b/package_manager/src/ProcessOutputCallback.php @@ -0,0 +1,113 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\package_manager; + +use PhpTuf\ComposerStager\Domain\Service\ProcessOutputCallback\ProcessOutputCallbackInterface; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; +use Psr\Log\NullLogger; + +/** + * A process callback for capturing output. + * + * @see \Symfony\Component\Process\Process + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class ProcessOutputCallback implements ProcessOutputCallbackInterface, LoggerAwareInterface { + + use LoggerAwareTrait; + + /** + * The output buffer. + * + * @var string + */ + private string $outBuffer = ''; + + /** + * The error buffer. + * + * @var string + */ + private string $errorBuffer = ''; + + /** + * Constructs a ProcessOutputCallback object. + */ + public function __construct() { + $this->setLogger(new NullLogger()); + } + + /** + * {@inheritdoc} + */ + public function __invoke(string $type, string $buffer): void { + if ($type === self::OUT) { + $this->outBuffer .= $buffer; + } + elseif ($type === self::ERR) { + $this->errorBuffer .= $buffer; + } + else { + throw new \InvalidArgumentException("Unsupported output type: '$type'"); + } + } + + /** + * Gets the output. + * + * If there is anything in the error buffer, it will be logged as a warning. + * + * @return string|null + * The output or NULL if there is none. + */ + public function getOutput(): ?string { + $error_output = $this->getErrorOutput(); + if ($error_output) { + $this->logger->warning($error_output); + } + return trim($this->outBuffer) !== '' ? $this->outBuffer : NULL; + } + + /** + * Gets the parsed JSON output. + * + * @return mixed + * The decoded JSON output or NULL if there isn't any. + */ + public function parseJsonOutput(): mixed { + $output = $this->getOutput(); + if ($output !== NULL) { + return json_decode($output, TRUE, 512, JSON_THROW_ON_ERROR); + } + return NULL; + } + + /** + * Gets the error output. + * + * @return string|null + * The error output or NULL if there isn't any. + */ + public function getErrorOutput(): ?string { + return trim($this->errorBuffer) !== '' ? $this->errorBuffer : NULL; + } + + /** + * Resets buffers. + * + * @return self + */ + public function reset(): self { + $this->errorBuffer = ''; + $this->outBuffer = ''; + return $this; + } + +} diff --git a/package_manager/tests/src/Kernel/ComposerInspectorTest.php b/package_manager/tests/src/Kernel/ComposerInspectorTest.php index 217b569231..a645a34b31 100644 --- a/package_manager/tests/src/Kernel/ComposerInspectorTest.php +++ b/package_manager/tests/src/Kernel/ComposerInspectorTest.php @@ -10,8 +10,8 @@ use Drupal\fixture_manipulator\ActiveFixtureManipulator; use Drupal\package_manager\ComposerInspector; use Drupal\package_manager\Exception\ComposerNotReadyException; use Drupal\package_manager\InstalledPackage; +use Drupal\package_manager\ProcessOutputCallback; use Drupal\package_manager\InstalledPackagesList; -use Drupal\package_manager\JsonProcessOutputCallback; use Drupal\Tests\package_manager\Traits\InstalledPackagesListTrait; use Drupal\package_manager\PathLocator; use PhpTuf\ComposerStager\Domain\Exception\PreconditionException; @@ -248,9 +248,9 @@ class ComposerInspectorTest extends PackageManagerKernelTestBase { ], ]); - /** @var \Drupal\package_manager\JsonProcessOutputCallback $callback */ + /** @var \Drupal\package_manager\ProcessOutputCallback $callback */ [, $callback] = $arguments_passed_to_runner; - $callback(JsonProcessOutputCallback::OUT, $command_output); + $callback($callback::OUT, $command_output); }; // We expect the runner to be called with two arguments: an array whose @@ -259,7 +259,7 @@ class ComposerInspectorTest extends PackageManagerKernelTestBase { // once, even though we call validate() twice in this test. $runner->run( Argument::withEntry(0, '--format=json'), - Argument::type(JsonProcessOutputCallback::class) + Argument::type(ProcessOutputCallback::class) )->will($pass_version_to_output_callback)->shouldBeCalledOnce(); // The runner should be called with `validate` as the first argument, but // it won't affect the outcome of this test. diff --git a/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php b/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php index 1999a918d5..bacf59f8b8 100644 --- a/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php +++ b/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php @@ -4,8 +4,10 @@ declare(strict_types = 1); namespace Drupal\Tests\package_manager\Kernel; +use ColinODell\PsrTestLogger\TestLogger; use Drupal\Component\FileSystem\FileSystem as DrupalFileSystem; use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\Logger\RfcLogLevel; use Drupal\Core\Site\Settings; use Drupal\fixture_manipulator\StageFixtureManipulator; use Drupal\KernelTests\KernelTestBase; @@ -93,6 +95,15 @@ abstract class PackageManagerKernelTestBase extends KernelTestBase { */ private readonly Filesystem $fileSystem; + /** + * A logger that will fail the test if Package Manager logs any errors. + * + * @var \ColinODell\PsrTestLogger\TestLogger + * + * @see ::tearDown() + */ + protected readonly TestLogger $failureLogger; + /** * {@inheritdoc} */ @@ -111,6 +122,13 @@ abstract class PackageManagerKernelTestBase extends KernelTestBase { // Make the update system think that all of System's post-update functions // have run. $this->registerPostUpdateFunctions(); + + // Ensure we can fail the test if any warnings, or worse, are logged by + // Package Manager. + // @see ::tearDown() + $this->failureLogger = new TestLogger(); + $this->container->get('logger.channel.package_manager') + ->addLogger($this->failureLogger); } /** @@ -134,6 +152,10 @@ abstract class PackageManagerKernelTestBase extends KernelTestBase { $container->getDefinition('package_manager.validator.disk_space') ->addTag('persist'); + // Ensure that our failure logger will survive container rebuilds. + $container->getDefinition('logger.channel.package_manager') + ->addTag('persist'); + foreach ($this->disableValidators as $service_id) { if ($container->hasDefinition($service_id)) { $container->getDefinition($service_id)->clearTag('event_subscriber'); @@ -388,6 +410,13 @@ abstract class PackageManagerKernelTestBase extends KernelTestBase { } StageFixtureManipulator::handleTearDown(); + + // Ensure no warnings (or worse) were logged by Package Manager. + $this->assertFalse($this->failureLogger->hasRecords(RfcLogLevel::EMERGENCY), 'Package Manager logged emergencies.'); + $this->assertFalse($this->failureLogger->hasRecords(RfcLogLevel::ALERT), 'Package Manager logged alerts.'); + $this->assertFalse($this->failureLogger->hasRecords(RfcLogLevel::CRITICAL), 'Package Manager logged critical errors.'); + $this->assertFalse($this->failureLogger->hasRecords(RfcLogLevel::ERROR), 'Package Manager logged errors.'); + $this->assertFalse($this->failureLogger->hasRecords(RfcLogLevel::WARNING), 'Package Manager logged warnings.'); } /** diff --git a/package_manager/tests/src/Kernel/ProjectInfoTest.php b/package_manager/tests/src/Kernel/ProjectInfoTest.php index adbd876e3b..e29be5c38e 100644 --- a/package_manager/tests/src/Kernel/ProjectInfoTest.php +++ b/package_manager/tests/src/Kernel/ProjectInfoTest.php @@ -6,7 +6,6 @@ namespace Drupal\Tests\package_manager\Kernel; use Drupal\Core\Logger\RfcLogLevel; use Drupal\package_manager\ProjectInfo; -use ColinODell\PsrTestLogger\TestLogger; /** * @coversDefaultClass \Drupal\package_manager\ProjectInfo @@ -130,10 +129,6 @@ class ProjectInfoTest extends PackageManagerKernelTestBase { * Tests a project that is not in the codebase. */ public function testNewProject(): void { - $logger = new TestLogger(); - $this->container->get('logger.factory') - ->get('package_manager') - ->addLogger($logger); $fixtures_directory = __DIR__ . '/../../fixtures/release-history/'; $metadata_fixtures['drupal'] = $fixtures_directory . 'drupal.9.8.2.xml'; $metadata_fixtures['aaa_package_manager_test'] = $fixtures_directory . 'aaa_package_manager_test.7.0.1.xml'; @@ -176,8 +171,10 @@ class ProjectInfoTest extends PackageManagerKernelTestBase { // the last checked time. $this->assertSame(123, $state->get('update.last_check')); - $this->assertTrue($logger->hasRecordThatContains('Invalid project format: Array', (string) RfcLogLevel::ERROR)); - $this->assertTrue($logger->hasRecordThatContains('[name] => AAA 8.x-5.x', (string) RfcLogLevel::ERROR)); + $this->assertTrue($this->failureLogger->hasRecordThatContains('Invalid project format: Array', (string) RfcLogLevel::ERROR)); + $this->assertTrue($this->failureLogger->hasRecordThatContains('[name] => AAA 8.x-5.x', (string) RfcLogLevel::ERROR)); + // Prevent the logged errors from causing failures during tear-down. + $this->failureLogger->reset(); } /** diff --git a/package_manager/tests/src/Kernel/SupportedReleaseValidatorTest.php b/package_manager/tests/src/Kernel/SupportedReleaseValidatorTest.php index 87f15c57de..2c945982b2 100644 --- a/package_manager/tests/src/Kernel/SupportedReleaseValidatorTest.php +++ b/package_manager/tests/src/Kernel/SupportedReleaseValidatorTest.php @@ -211,6 +211,9 @@ class SupportedReleaseValidatorTest extends PackageManagerKernelTestBase { // @see \Drupal\package_manager\Validator\SupportedReleaseValidator::checkStagedReleases() $stage_manipulator->setVersion('drupal/dependency', '9.8.1'); $this->assertResults($expected_results, PreApplyEvent::class); + // Ensure that any errors arising from invalid project info (which we expect + // in this test) will not fail the test during tear-down. + $this->failureLogger->reset(); } } diff --git a/package_manager/tests/src/Unit/JsonProcessOutputCallbackTest.php b/package_manager/tests/src/Unit/JsonProcessOutputCallbackTest.php deleted file mode 100644 index e87159e91b..0000000000 --- a/package_manager/tests/src/Unit/JsonProcessOutputCallbackTest.php +++ /dev/null @@ -1,68 +0,0 @@ -<?php - -declare(strict_types = 1); - -namespace Drupal\Tests\package_manager\Unit; - -use Drupal\package_manager\JsonProcessOutputCallback; -use Drupal\Tests\UnitTestCase; -use PhpTuf\ComposerStager\Domain\Service\ProcessOutputCallback\ProcessOutputCallbackInterface; - -/** - * @coversDefaultClass \Drupal\package_manager\JsonProcessOutputCallback - * - * @group package_manager - */ -class JsonProcessOutputCallbackTest extends UnitTestCase { - - /** - * @covers ::__invoke - * @covers ::getOutputData - */ - public function testGetOutputData(): void { - // Create a data array that has 1 '*' character to allow easily splitting - // up the JSON encoded string over multiple lines. - $data = [ - 'value' => 'I have value!*', - 'another value' => 'I have another value!', - 'one' => 1, - ]; - $json_string = json_encode($data, JSON_THROW_ON_ERROR); - $lines = explode('*', $json_string); - $lines[0] .= '*'; - // Confirm that 2 string concatenated together will recreate the original - // data. - $this->assertSame($data, json_decode($lines[0] . $lines[1], TRUE)); - $callback = new JsonProcessOutputCallback(); - $callback(ProcessOutputCallbackInterface::OUT, $lines[0]); - $callback(ProcessOutputCallbackInterface::OUT, $lines[1]); - $this->assertSame($data, $callback->getOutputData()); - - $callback = new JsonProcessOutputCallback(); - $callback(ProcessOutputCallbackInterface::OUT, '1'); - $this->assertSame(1, $callback->getOutputData()); - } - - /** - * @covers ::getOutputData - */ - public function testNoInvokeCall(): void { - $callback = new JsonProcessOutputCallback(); - $this->assertNull($callback->getOutputData()); - } - - /** - * @covers ::getOutputData - */ - public function testError(): void { - $callback = new JsonProcessOutputCallback(); - $callback(ProcessOutputCallbackInterface::OUT, '1'); - $callback(ProcessOutputCallbackInterface::ERR, 'Oh no, what happened!!!!!'); - $callback(ProcessOutputCallbackInterface::OUT, '2'); - $callback(ProcessOutputCallbackInterface::ERR, 'Really what happened????'); - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Oh no, what happened!!!!!Really what happened????'); - $callback->getOutputData(); - } - -} diff --git a/package_manager/tests/src/Unit/ProcessOutputCallbackTest.php b/package_manager/tests/src/Unit/ProcessOutputCallbackTest.php new file mode 100644 index 0000000000..a1443385f8 --- /dev/null +++ b/package_manager/tests/src/Unit/ProcessOutputCallbackTest.php @@ -0,0 +1,138 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\Tests\package_manager\Unit; + +use ColinODell\PsrTestLogger\TestLogger; +use Drupal\package_manager\ProcessOutputCallback; +use Drupal\Tests\UnitTestCase; + +/** + * @covers \Drupal\package_manager\ProcessOutputCallback + * @group package_manager + */ +class ProcessOutputCallbackTest extends UnitTestCase { + + /** + * Tests what happens when the output buffer has invalid JSON. + */ + public function testInvalidJson(): void { + $callback = new ProcessOutputCallback(); + $callback($callback::OUT, '{A string of invalid JSON! 😈'); + + $this->expectException(\JsonException::class); + $this->expectExceptionMessage('Syntax error'); + $callback->parseJsonOutput(); + } + + /** + * Tests that an invalid buffer type throws an exception. + */ + public function testInvalidBufferType(): void { + $callback = new ProcessOutputCallback(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("Unsupported output type: 'telegram'"); + $callback('telegram', 'Into the void...'); + } + + /** + * Tests what happens when there is error output only. + */ + public function testErrorOutputOnly(): void { + $callback = new ProcessOutputCallback(); + $logger = new TestLogger(); + $callback->setLogger($logger); + + $error_text = 'What happened?'; + $callback($callback::ERR, $error_text); + + $this->assertSame($error_text, $callback->getErrorOutput()); + // The error should not yet be logged. + $this->assertEmpty($logger->records); + + // There should be no output data, but calling getOutput() should log the + // error. + $this->assertNull($callback->getOutput()); + $this->assertNull($callback->parseJsonOutput()); + $this->assertTrue($logger->hasWarning($error_text)); + + // Resetting the callback should clear the error buffer but the log should + // still have the error from before. + $callback->reset(); + $this->assertTrue($logger->hasWarning($error_text)); + } + + /** + * Tests the full lifecycle of a ProcessOutputCallback object. + */ + public function testCallback(): void { + $callback = new ProcessOutputCallback(); + $logger = new TestLogger(); + $callback->setLogger($logger); + + // The buffers should initially be empty, and nothing should be logged. + $this->assertNull($callback->getOutput()); + $this->assertNull($callback->getErrorOutput()); + $this->assertNull($callback->parseJsonOutput()); + $this->assertEmpty($logger->records); + + // Send valid JSON data to the callback, one line at a time. + $data = [ + 'value' => 'I have value!', + 'another value' => 'I have another value!', + 'one' => 1, + ]; + $json = json_encode($data, JSON_PRETTY_PRINT); + // Ensure the JSON is a multi-line string. + $this->assertGreaterThan(1, substr_count($json, "\n")); + foreach (explode("\n", $json) as $line) { + $callback($callback::OUT, "$line\n"); + } + $this->assertSame("$json\n", $callback->getOutput()); + // Ensure that parseJsonOutput() can parse the data without errors. + $this->assertSame($data, $callback->parseJsonOutput()); + $this->assertNull($callback->getErrorOutput()); + $this->assertEmpty($logger->records); + + // If we send error output, it should be logged, but we should still be able + // to get the data we already sent. + $callback($callback::ERR, 'Oh no, what happened?'); + $callback($callback::ERR, 'Really what happened?!'); + $this->assertSame($data, $callback->parseJsonOutput()); + $this->assertSame('Oh no, what happened?Really what happened?!', $callback->getErrorOutput()); + $this->assertTrue($logger->hasWarning('Oh no, what happened?Really what happened?!')); + + // Send more output and error data to the callback; they should be appended + // to the data we previously sent. + $callback($callback::OUT, '{}'); + $callback($callback::ERR, 'new Error 1!'); + $callback($callback::ERR, 'new Error 2!'); + // The output buffer will no longer be valid JSON, so don't try to parse it. + $this->assertSame("$json\n{}", $callback->getOutput()); + $expected_error = 'Oh no, what happened?Really what happened?!new Error 1!new Error 2!'; + $this->assertSame($expected_error, $callback->getErrorOutput()); + $this->assertTrue($logger->hasWarning($expected_error)); + // The previously logged error output should still be there. + $this->assertTrue($logger->hasWarning('Oh no, what happened?Really what happened?!')); + + // Clear all stored output and errors. + $callback->reset(); + $this->assertNull($callback->getOutput()); + $this->assertNull($callback->getErrorOutput()); + $this->assertNull($callback->parseJsonOutput()); + + // Send more output and error data. + $callback($callback::OUT, 'Bonjour!'); + $callback($callback::ERR, 'You continue to annoy me.'); + // We should now only see the stuff we just sent... + $this->assertSame('Bonjour!', $callback->getOutput()); + $this->assertSame('You continue to annoy me.', $callback->getErrorOutput()); + $this->assertTrue($logger->hasWarning('You continue to annoy me.')); + // ...but the previously logged errors should still be there. + $this->assertTrue($logger->hasWarning('Oh no, what happened?Really what happened?!new Error 1!new Error 2!')); + $this->assertTrue($logger->hasWarning('Oh no, what happened?Really what happened?!')); + } + +} -- GitLab