From 684d46bee3728ad80634cc57d82062f81b15ee57 Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Thu, 22 Aug 2024 09:31:18 +0200 Subject: [PATCH] fix(Module/WPLoader) load WordPress in _beforeSuite method fixes #744 Change the code of the `WPLoader` module to load WordPress, whether the `loadOnly` flag is set to `true` or `false`, in the `_beforeSuite` method. This removes the need, for the module, to rely on the dispatching, subscribing and need to do so, to the main Codeception system through the Codeception event dispatcher. Kudos to @lxbdr for the proposed solution. --- src/Module/WPLoader.php | 27 ++- src/WordPress/LoadSandbox.php | 3 +- tests/_support/Fork.php | 208 ++++++++++++++++++ .../WPBrowser/Module/WPLoaderLoadOnlyTest.php | 134 +++++++++++ 4 files changed, 367 insertions(+), 5 deletions(-) create mode 100644 tests/_support/Fork.php create mode 100644 tests/unit/lucatume/WPBrowser/Module/WPLoaderLoadOnlyTest.php diff --git a/src/Module/WPLoader.php b/src/Module/WPLoader.php index d2de1f706..ed7b087ff 100644 --- a/src/Module/WPLoader.php +++ b/src/Module/WPLoader.php @@ -178,6 +178,7 @@ class WPLoader extends Module private bool $earlyExit = true; private ?DatabaseInterface $db = null; private ?CodeExecutionFactory $codeExecutionFactory = null; + private bool $didLoadWordPress = false; public function _getBootstrapOutput(): string { @@ -189,6 +190,11 @@ public function _getInstallationOutput(): string return $this->installationOutput; } + public function _didLoadWordPress(): bool + { + return $this->didLoadWordPress; + } + protected function validateConfig(): void { // Coming from required fields, the values are now defined. @@ -489,10 +495,6 @@ public function _initialize(): void $this->checkInstallationToLoadOnly(); $this->debug('The WordPress installation will be loaded after all other modules have been initialized.'); - Dispatcher::addListener(Events::SUITE_BEFORE, function (): void { - $this->_loadWordPress(true); - }); - return; } @@ -506,6 +508,17 @@ public function _initialize(): void $this->_loadWordPress(); } + /** + * @param array $settings + * + * @return void + */ + public function _beforeSuite(array $settings = []) + { + parent::_beforeSuite($settings); + $this->_loadWordPress(); + } + /** * Returns the absolute path to the WordPress root folder or a path within it.. * @@ -559,6 +572,10 @@ private function ensureDbModuleCompat(): void */ public function _loadWordPress(?bool $loadOnly = null): void { + if ($this->didLoadWordPress) { + return; + } + $config = $this->config; /** @var array{loadOnly: bool} $config */ $loadOnly = $loadOnly ?? $config['loadOnly']; @@ -574,6 +591,8 @@ public function _loadWordPress(?bool $loadOnly = null): void $this->installAndBootstrapInstallation(); } + $this->didLoadWordPress = true; + wp_cache_flush(); $this->factoryStore = new FactoryStore(); diff --git a/src/WordPress/LoadSandbox.php b/src/WordPress/LoadSandbox.php index b5e5fdcda..a7bae3e2b 100644 --- a/src/WordPress/LoadSandbox.php +++ b/src/WordPress/LoadSandbox.php @@ -69,6 +69,7 @@ class_exists(InstallationException::class); if (did_action('wp_loaded') >= 1) { return true; } + $reason = 'action wp_loaded not fired.'; if (count($this->redirects) > 0 && $this->redirects[0][1] === 302 @@ -101,7 +102,7 @@ class_exists(InstallationException::class); } // We do not know what happened, throw and try to be helpful. - throw InstallationException::becauseWordPressFailedToLoad($bodyContent); + throw InstallationException::becauseWordPressFailedToLoad($bodyContent ?: $reason); } public function logRedirection(string $location, int $status): string diff --git a/tests/_support/Fork.php b/tests/_support/Fork.php new file mode 100644 index 000000000..305ff0fdf --- /dev/null +++ b/tests/_support/Fork.php @@ -0,0 +1,208 @@ + + */ + private int $ipcSocketChunkSize = 2048; + private string $terminator = self::DEFAULT_TERMINATOR; + + public static function executeClosure( + \Closure $callback, + bool $quiet = false, + int $ipcSocketChunkSize = 2048, + string $terminator = self::DEFAULT_TERMINATOR + ): mixed { + return (new self($callback)) + ->setQuiet($quiet) + ->setIpcSocketChunkSize($ipcSocketChunkSize) + ->setTerminator($terminator) + ->execute(); + } + + public function __construct(\Closure $callback) + { + $this->callback = $callback; + } + + public function setQuiet(bool $quiet): self + { + $this->quiet = $quiet; + return $this; + } + + public function execute(): mixed + { + if (!(function_exists('pcntl_fork') && function_exists('posix_kill'))) { + throw new \RuntimeException('pcntl and posix extensions missing.'); + } + + $sockets = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); + + if ($sockets === false) { + throw new \RuntimeException('Failed to create socket pair'); + } + + /** @var array{0: resource, 1: resource} $sockets */ + + $pid = pcntl_fork(); + if ($pid === -1) { + throw new \RuntimeException('Failed to fork'); + } + + + if ($pid === 0) { + $this->executeFork($sockets); + } + + return $this->executeMain($pid, $sockets); + } + + public function setIpcSocketChunkSize(int $ipcSocketChunkSize): self + { + if ($ipcSocketChunkSize < 0) { + throw new \InvalidArgumentException('ipcSocketChunkSize must be a positive integer'); + } + + $this->ipcSocketChunkSize = $ipcSocketChunkSize; + return $this; + } + + public function setTerminator(string $terminator): self + { + $this->terminator = $terminator; + return $this; + } + + /** + * @param array{0: resource, 1: resource} $sockets + */ + private function executeFork(array $sockets): void + { + fclose($sockets[1]); + $ipcSocket = $sockets[0]; + $pid = getmypid(); + $didWriteTerminator = false; + $terminator = $this->terminator; + + if ($pid === false) { + die('Failed to get pid'); + } + + if ($this->quiet) { + fclose(STDOUT); + fclose(STDERR); + } + + register_shutdown_function(static function () use ($pid, $ipcSocket, &$didWriteTerminator, $terminator) { + if (!$didWriteTerminator) { + fwrite($ipcSocket, $terminator); + $didWriteTerminator = true; + } + fclose($ipcSocket); + /** @noinspection PhpComposerExtensionStubsInspection */ + posix_kill($pid, 9 /* SIGKILL */); + }); + + try { + $result = ($this->callback)(); + $resultClosure = new SerializableClosure(static function () use ($result) { + return $result; + }); + $resultPayload = serialize($resultClosure); + } catch (\Throwable $throwable) { + $resultPayload = serialize(new SerializableThrowable($throwable)); + } finally { + if (!isset($resultPayload)) { + // Something went wrong. + fwrite($ipcSocket, serialize(null)); + fwrite($ipcSocket, $this->terminator); + $didWriteTerminator = true; + /** @noinspection PhpComposerExtensionStubsInspection */ + posix_kill($pid, 9 /* SIGKILL */); + } + } + + $offset = 0; + while (true) { + $chunk = substr($resultPayload, $offset, $this->ipcSocketChunkSize); + + if ($chunk === '') { + break; + } + + fwrite($ipcSocket, $chunk); + $offset += $this->ipcSocketChunkSize; + } + fwrite($ipcSocket, $this->terminator); + $didWriteTerminator = true; + fclose($ipcSocket); + + // Kill the child process now with a signal that will not run shutdown handlers. + /** @noinspection PhpComposerExtensionStubsInspection */ + posix_kill($pid, 9 /* SIGKILL */); + } + + /** + * @param array{0: resource, 1: resource} $sockets + * @throws \Throwable + */ + private function executeMain(int $pid, array $sockets): mixed + { + fclose($sockets[0]); + $resultPayload = ''; + + /** @noinspection PhpComposerExtensionStubsInspection */ + while (pcntl_wait($status, 1 /* WNOHANG */) <= 0) { + $chunk = fread($sockets[1], $this->ipcSocketChunkSize); + $resultPayload .= $chunk; + } + + while (!str_ends_with($resultPayload, $this->terminator)) { + $chunk = fread($sockets[1], $this->ipcSocketChunkSize); + $resultPayload .= $chunk; + } + + fclose($sockets[1]); + + if (str_ends_with($resultPayload, $this->terminator)) { + $resultPayload = substr($resultPayload, 0, -strlen($this->terminator)); + } + + try { + /** @var SerializableClosure|SerializableThrowable $unserializedPayload */ + $unserializedPayload = @unserialize($resultPayload); + $result = $unserializedPayload instanceof SerializableThrowable ? + $unserializedPayload->getThrowable() : $unserializedPayload->getClosure()(); + } catch (\Throwable $t) { + $result = $resultPayload; + } + + if ($result instanceof \Throwable) { + throw $result; + } + + /** @noinspection PhpComposerExtensionStubsInspection */ + posix_kill($pid, 9 /* SIGKILL */); + + return $result; + } +} diff --git a/tests/unit/lucatume/WPBrowser/Module/WPLoaderLoadOnlyTest.php b/tests/unit/lucatume/WPBrowser/Module/WPLoaderLoadOnlyTest.php new file mode 100644 index 000000000..a09ee9bcd --- /dev/null +++ b/tests/unit/lucatume/WPBrowser/Module/WPLoaderLoadOnlyTest.php @@ -0,0 +1,134 @@ + [ + 'version.php' => <<< PHP + <<< PHP + ' 'makeMockWordPressInstallation(); + $moduleContainer = new ModuleContainer(new Di(), []); + $module = new WPLoader($moduleContainer, [ + 'dbUrl' => $dbUrl, + 'wpRootFolder' => $wpRootFolder, + 'loadOnly' => true, + ]); + + Fork::executeClosure(function () use ($module) { + // WordPress' functions are stubbed by wordpress-stubs in unit tests: override them to do something. + $did_actions = []; + uopz_set_return('do_action', static function ($action) use (&$did_actions) { + $did_actions[$action] = true; + }, true); + uopz_set_return('did_action', static function ($action) use (&$did_actions) { + return isset($did_actions[$action]); + }, true); + // Partial mocking the function that would load WordPress. + uopz_set_return(WPLoader::class, 'installAndBootstrapInstallation', function () { + $this->fail('The WPLoader::installAndBootstrapInstallation method should not be called'); + }, true); + + $module->_initialize(); + + $this->assertFalse($module->_didLoadWordPress()); + + $module->_beforeSuite(); + + $this->assertTrue($module->_didLoadWordPress()); + }); + } + + public function testWillLoadWordPressInInitializeWhenLoadOnlyIsFalse(): void + { + [$wpRootFolder, $dbUrl] = $this->makeMockWordPressInstallation(); + $moduleContainer = new ModuleContainer(new Di(), []); + $module = new WPLoader($moduleContainer, [ + 'dbUrl' => $dbUrl, + 'wpRootFolder' => $wpRootFolder, + 'loadOnly' => false, + ]); + + Fork::executeClosure(function () use ($module) { + // WordPress' functions are stubbed by wordpress-stubs in unit tests: override them to do something. + $did_actions = []; + uopz_set_return('do_action', static function ($action) use (&$did_actions) { + $did_actions[$action] = true; + }, true); + uopz_set_return('did_action', static function ($action) use (&$did_actions) { + return isset($did_actions[$action]); + }, true); + // Partial mocking the function that would load WordPress. + uopz_set_return(WPLoader::class, 'installAndBootstrapInstallation', function () { + return true; + }, true); + + $module->_initialize(); + + $this->assertTrue($module->_didLoadWordPress()); + + $module->_beforeSuite(); + + $this->assertTrue($module->_didLoadWordPress()); + }); + } +}