From 7bcd1206974ec3126e33b461aa949425aff21177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 12 May 2023 13:08:32 +0200 Subject: [PATCH] Update test suite to ensure 100% code coverage --- .github/workflows/ci.yml | 13 +++++++-- README.md | 10 +++++++ src/Factory.php | 20 +++++++++---- src/Io/ProcessIoDatabase.php | 9 +++--- tests/FunctionalDatabaseTest.php | 50 ++++++++++++++++++++++++++++++++ tests/FunctionalExampleTest.php | 4 +-- 6 files changed, 92 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 620de65..ce83358 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,11 +33,18 @@ jobs: extensions: sqlite3 coverage: xdebug ini-file: development - - run: composer install - - run: vendor/bin/phpunit --coverage-text + - run: composer install || (sleep 5 && composer install) # retry install to avoid flaky legacy PHP results + - run: vendor/bin/phpunit --coverage-text --coverage-clover=clover.xml if: ${{ matrix.php >= 7.3 }} - - run: vendor/bin/phpunit --coverage-text -c phpunit.xml.legacy + - run: vendor/bin/phpunit --coverage-text --coverage-clover=clover.xml -c phpunit.xml.legacy if: ${{ matrix.php < 7.3 }} + - name: Check 100% code coverage + if: ${{ matrix.os != 'windows-2022' }} + shell: php {0} + run: | + project->metrics; + exit((int) $metrics['statements'] === (int) $metrics['coveredstatements'] ? 0 : 1); - run: cd tests/install-as-dep && composer install && php query.php - run: cd tests/install-as-dep && php -d phar.readonly=0 vendor/bin/phar-composer build . query.phar && php query.phar - run: cd tests/install-as-dep && mv query.phar query.ext && php query.ext diff --git a/README.md b/README.md index 9bd500b..ce830f9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # clue/reactphp-sqlite [![CI status](https://github.com/clue/reactphp-sqlite/actions/workflows/ci.yml/badge.svg)](https://github.com/clue/reactphp-sqlite/actions) +[![code coverage](https://img.shields.io/badge/code%20coverage-100%25-success)](#tests) [![installs on Packagist](https://img.shields.io/packagist/dt/clue/reactphp-sqlite?color=blue&label=installs%20on%20Packagist)](https://packagist.org/packages/clue/reactphp-sqlite) Async SQLite database, lightweight non-blocking process wrapper around file-based database extension (`ext-sqlite3`), @@ -470,6 +471,15 @@ To run the test suite, go to the project root and run: vendor/bin/phpunit ``` +The test suite is set up to always ensure 100% code coverage across all +supported environments (except the platform-specific code that does not execute +on Windows). If you have the Xdebug extension installed, you can also generate a +code coverage report locally like this: + +```bash +XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text +``` + ## License This project is released under the permissive [MIT license](LICENSE). diff --git a/src/Factory.php b/src/Factory.php index 655e2bb..decb998 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -128,8 +128,9 @@ public function open($filename, $flags = null) return \React\Promise\resolve(new BlockingDatabase($filename, $flags)); } catch (\Exception $e) { return \React\Promise\reject(new \RuntimeException($e->getMessage()) ); - } catch (\Error $e) { - return \React\Promise\reject(new \RuntimeException($e->getMessage())); + } catch (\Error $e) { // @codeCoverageIgnore + assert(\PHP_VERSION_ID >= 70000); // @codeCoverageIgnore + return \React\Promise\reject(new \RuntimeException($e->getMessage())); // @codeCoverageIgnore } } @@ -229,12 +230,15 @@ private function openProcessIo($filename, $flags = null) $cwd = null; $worker = \dirname(__DIR__) . '/res/sqlite-worker.php'; + // launch worker process directly or inside Phar by mapping relative paths (covered by functional test suite) + // @codeCoverageIgnoreStart if (\class_exists('Phar', false) && ($phar = \Phar::running(false)) !== '') { - $worker = '-r' . 'Phar::loadPhar(' . var_export($phar, true) . ');require(' . \var_export($worker, true) . ');'; // @codeCoverageIgnore + $worker = '-r' . 'Phar::loadPhar(' . var_export($phar, true) . ');require(' . \var_export($worker, true) . ');'; } else { $cwd = __DIR__ . '/../res'; $worker = \basename($worker); } + // @codeCoverageIgnoreEnd $command = 'exec ' . \escapeshellarg($this->bin) . ' ' . escapeshellarg($worker); // Try to get list of all open FDs (Linux/Mac and others) @@ -297,12 +301,15 @@ private function openSocketIo($filename, $flags = null) $cwd = null; $worker = \dirname(__DIR__) . '/res/sqlite-worker.php'; + // launch worker process directly or inside Phar by mapping relative paths (covered by functional test suite) + // @codeCoverageIgnoreStart if (\class_exists('Phar', false) && ($phar = \Phar::running(false)) !== '') { - $worker = '-r' . 'Phar::loadPhar(' . var_export($phar, true) . ');require(' . \var_export($worker, true) . ');'; // @codeCoverageIgnore + $worker = '-r' . 'Phar::loadPhar(' . var_export($phar, true) . ');require(' . \var_export($worker, true) . ');'; } else { $cwd = __DIR__ . '/../res'; $worker = \basename($worker); } + // @codeCoverageIgnoreEnd $command = \escapeshellarg($this->bin) . ' ' . escapeshellarg($worker); // launch process without default STDIO pipes, but inherit STDERR @@ -316,9 +323,12 @@ private function openSocketIo($filename, $flags = null) // start temporary socket on random address $server = @stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr); if ($server === false) { + // report error if temporary socket server can not be started (unlikely) + // @codeCoverageIgnoreStart return \React\Promise\reject( new \RuntimeException('Unable to start temporary socket I/O server: ' . $errstr, $errno) ); + // @codeCoverageIgnoreEnd } // pass random server address to child process to connect back to parent process @@ -342,7 +352,7 @@ private function openSocketIo($filename, $flags = null) fclose($server); $process->terminate(); - $deferred->reject(new \RuntimeException('No connection detected')); + $deferred->reject(new \RuntimeException('Opening database socket timed out')); }); $process->on('exit', function () use ($deferred, $server, $timeout) { diff --git a/src/Io/ProcessIoDatabase.php b/src/Io/ProcessIoDatabase.php index fc31b1a..a2797af 100644 --- a/src/Io/ProcessIoDatabase.php +++ b/src/Io/ProcessIoDatabase.php @@ -80,9 +80,9 @@ public function query($sql, array $params = array()) foreach ($params as &$value) { if (\is_string($value) && \preg_match('/[\x00-\x08\x11\x12\x14-\x1f\x7f]/u', $value) !== 0) { $value = ['base64' => \base64_encode($value)]; - } elseif (\is_float($value) && \PHP_VERSION_ID < 50606) { + } elseif (\is_float($value) && \PHP_VERSION_ID < 50606) { // @codeCoverageIgnoreStart $value = ['float' => $value]; - } + } // @codeCoverageIgnoreEnd } return $this->send('query', array($sql, $params))->then(function ($data) { @@ -98,9 +98,10 @@ public function query($sql, array $params = array()) foreach ($row as &$value) { if (isset($value['base64'])) { $value = \base64_decode($value['base64']); - } elseif (isset($value['float'])) { + } elseif (isset($value['float'])) { // @codeCoverageIgnoreStart + assert(\PHP_VERSION_ID < 50606); $value = (float)$value['float']; - } + } // @codeCoverageIgnoreEnd } } } diff --git a/tests/FunctionalDatabaseTest.php b/tests/FunctionalDatabaseTest.php index 5c8b89c..09e48fe 100644 --- a/tests/FunctionalDatabaseTest.php +++ b/tests/FunctionalDatabaseTest.php @@ -797,6 +797,56 @@ public function testQuerySelectEmptyResolvesWithEmptyResultSetWithColumnsAndNoRo $this->assertSame([], $data->rows); } + public function testCancelOpenWithSocketRejectsPromise() + { + $factory = new Factory(); + + $ref = new \ReflectionProperty($factory, 'useSocket'); + $ref->setAccessible(true); + $ref->setValue($factory, true); + + $promise = $factory->open(':memory:'); + $promise->cancel(); + + $exception = null; + $promise->then(null, function ($reason) use (&$exception) { + $exception = $reason; + }); + + $this->assertInstanceOf('RuntimeException', $exception); + $this->assertEquals('Opening database cancelled', $exception->getMessage()); + } + + public function testOpenWithSocketWillRejectWhenSocketConnectionTimesOut() + { + $timer = null; + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addTimer')->with(5.0, $this->callback(function (callable $callback) use (&$timer) { + $timer = $callback; + return true; + })); + $loop->expects($this->never())->method('cancelTimer'); + + $factory = new Factory($loop); + + $ref = new \ReflectionProperty($factory, 'useSocket'); + $ref->setAccessible(true); + $ref->setValue($factory, true); + + $promise = $factory->open(':memory:'); + + $this->assertNotNull($timer); + $timer(); + + $exception = null; + $promise->then(null, function ($reason) use (&$exception) { + $exception = $reason; + }); + + $this->assertInstanceOf('RuntimeException', $exception); + $this->assertEquals('Opening database socket timed out', $exception->getMessage()); + } + protected function expectCallableNever() { $mock = $this->createCallableMock(); diff --git a/tests/FunctionalExampleTest.php b/tests/FunctionalExampleTest.php index 3036713..8283940 100644 --- a/tests/FunctionalExampleTest.php +++ b/tests/FunctionalExampleTest.php @@ -26,7 +26,7 @@ public function testQueryExampleExecutedWithCgiReturnsDefaultValueAfterContentTy $this->markTestSkipped('Unable to execute "php-cgi"'); } - $output = $this->execExample('php-cgi query.php'); + $output = $this->execExample('php-cgi -dopcache.jit=off query.php'); $this->assertStringEndsWith("\n\n" . 'value' . "\n" . '42' . "\n", $output); } @@ -55,7 +55,7 @@ public function testQueryExampleExecutedWithCgiAndOpenBasedirRestrictedRunsDefau $this->markTestSkipped('Unable to execute "php-cgi" or "php"'); } - $output = $this->execExample('php-cgi -dopen_basedir=' . escapeshellarg(dirname(__DIR__)) . ' query.php'); + $output = $this->execExample('php-cgi -dopcache.jit=off -dopen_basedir=' . escapeshellarg(dirname(__DIR__)) . ' query.php'); $this->assertStringEndsWith("\n\n" . 'value' . "\n" . '42' . "\n", $output); }