From c8761e6bb527bc8ab1da41444cc3451d8495edb3 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 1 Nov 2021 21:31:10 +0100 Subject: [PATCH 1/5] Dumper: improved encoding of strings, added colors --- src/Framework/Dumper.php | 102 +++++++++++++++------- tests/Framework/Assert.match.phpt | 3 +- tests/Framework/Dumper.dumpException.phpt | 2 +- tests/Framework/Dumper.toLine.phpt | 9 +- tests/Framework/Dumper.toPhp.phpt | 7 +- 5 files changed, 85 insertions(+), 38 deletions(-) diff --git a/src/Framework/Dumper.php b/src/Framework/Dumper.php index abd17349..c8b7af00 100644 --- a/src/Framework/Dumper.php +++ b/src/Framework/Dumper.php @@ -33,17 +33,6 @@ class Dumper */ public static function toLine($var): string { - static $table; - if ($table === null) { - foreach (array_merge(range("\x00", "\x1F"), range("\x7F", "\xFF")) as $ch) { - $table[$ch] = '\x' . str_pad(dechex(ord($ch)), 2, '0', STR_PAD_LEFT); - } - $table['\\'] = '\\\\'; - $table["\r"] = '\r'; - $table["\n"] = '\n'; - $table["\t"] = '\t'; - } - if (is_bool($var)) { return $var ? 'TRUE' : 'FALSE'; @@ -62,9 +51,7 @@ public static function toLine($var): string } elseif (strlen($var) > self::$maxLength) { $var = substr($var, 0, self::$maxLength) . '...'; } - return preg_match('#[^\x09\x0A\x0D\x20-\x7E\xA0-\x{10FFFF}]#u', $var) || preg_last_error() - ? '"' . strtr($var, $table) . '"' - : "'$var'"; + return self::encodeStringLine($var); } elseif (is_array($var)) { $out = ''; @@ -146,20 +133,10 @@ private static function _toPhp(&$var, array &$list = [], int $level = 0, int &$l } elseif ($var === null) { return 'null'; - } elseif (is_string($var) && (preg_match('#[^\x09\x20-\x7E\xA0-\x{10FFFF}]#u', $var) || preg_last_error())) { - static $table; - if ($table === null) { - foreach (array_merge(range("\x00", "\x1F"), range("\x7F", "\xFF")) as $ch) { - $table[$ch] = '\x' . str_pad(dechex(ord($ch)), 2, '0', STR_PAD_LEFT); - } - $table['\\'] = '\\\\'; - $table["\r"] = '\r'; - $table["\n"] = '\n'; - $table["\t"] = '\t'; - $table['$'] = '\$'; - $table['"'] = '\"'; - } - return '"' . strtr($var, $table) . '"'; + } elseif (is_string($var)) { + $res = self::encodeStringPhp($var); + $line += substr_count($res, "\n"); + return $res; } elseif (is_array($var)) { $space = str_repeat("\t", $level); @@ -242,9 +219,72 @@ private static function _toPhp(&$var, array &$list = [], int $level = 0, int &$l return '/* resource ' . get_resource_type($var) . ' */'; } else { - $res = var_export($var, true); - $line += substr_count($res, "\n"); - return $res; + return var_export($var, true); + } + } + + + private static function encodeStringPhp(string $s): string + { + static $special = [ + "\r" => '\r', + "\n" => '\n', + "\t" => "\t", + "\e" => '\e', + '\\' => '\\\\', + ]; + $utf8 = preg_match('##u', $s); + $escaped = preg_replace_callback( + $utf8 ? '#[\p{C}\\\\]#u' : '#[\x00-\x1F\x7F-\xFF\\\\]#', + function ($m) use ($special) { + return $special[$m[0]] ?? (strlen($m[0]) === 1 + ? '\x' . str_pad(strtoupper(dechex(ord($m[0]))), 2, '0', STR_PAD_LEFT) . '' + : '\u{' . strtoupper(ltrim(dechex(self::utf8Ord($m[0])), '0')) . '}'); + }, + $s + ); + return $s === str_replace('\\\\', '\\', $escaped) + ? "'" . preg_replace('#\'|\\\\(?=[\'\\\\]|$)#D', '\\\\$0', $s) . "'" + : '"' . addcslashes($escaped, '"$') . '"'; + } + + + private static function encodeStringLine(string $s): string + { + static $special = [ + "\r" => "\\r\r", + "\n" => "\\n\n", + "\t" => "\\t\t", + "\e" => '\\e', + "'" => "'", + ]; + $utf8 = preg_match('##u', $s); + $escaped = preg_replace_callback( + $utf8 ? '#[\p{C}\']#u' : '#[\x00-\x1F\x7F-\xFF\']#', + function ($m) use ($special) { + return "\e[22m" + . ($special[$m[0]] ?? (strlen($m[0]) === 1 + ? '\x' . str_pad(strtoupper(dechex(ord($m[0]))), 2, '0', STR_PAD_LEFT) + : '\u{' . strtoupper(ltrim(dechex(self::utf8Ord($m[0])), '0')) . '}')) + . "\e[1m"; + }, + $s + ); + return "'" . $escaped . "'"; + } + + + private static function utf8Ord(string $c): int + { + $ord0 = ord($c[0]); + if ($ord0 < 0x80) { + return $ord0; + } elseif ($ord0 < 0xE0) { + return ($ord0 << 6) + ord($c[1]) - 0x3080; + } elseif ($ord0 < 0xF0) { + return ($ord0 << 12) + (ord($c[1]) << 6) + ord($c[2]) - 0xE2080; + } else { + return ($ord0 << 18) + (ord($c[1]) << 12) + (ord($c[2]) << 6) + ord($c[3]) - 0x3C82080; } } diff --git a/tests/Framework/Assert.match.phpt b/tests/Framework/Assert.match.phpt index 1d71d70d..0219f33b 100644 --- a/tests/Framework/Assert.match.phpt +++ b/tests/Framework/Assert.match.phpt @@ -3,6 +3,7 @@ declare(strict_types=1); use Tester\Assert; +use Tester\Dumper; require __DIR__ . '/../bootstrap.php'; @@ -92,7 +93,7 @@ foreach ($notMatches as [$expected, $actual, $expected2, $actual2]) { $ex = Assert::exception(function () use ($expected, $actual) { Assert::match($expected, $actual); - }, Tester\AssertException::class, "'$actual3' should match '$expected3'"); + }, Tester\AssertException::class, Dumper::toLine($actual3) . " should match " . Dumper::toLine($expected3)); Assert::same($expected2, $ex->expected); Assert::same($actual2, $ex->actual); diff --git a/tests/Framework/Dumper.dumpException.phpt b/tests/Framework/Dumper.dumpException.phpt index 86b15153..f2d988ff 100644 --- a/tests/Framework/Dumper.dumpException.phpt +++ b/tests/Framework/Dumper.dumpException.phpt @@ -21,7 +21,7 @@ $cases = [ 'Failed: NULL should not be NULL' => function () { Assert::notSame(null, null); }, 'Failed: boolean should be instance of x' => function () { Assert::type('x', true); }, 'Failed: resource should be int' => function () { Assert::type('int', fopen(__FILE__, 'r')); }, - "Failed: 'Hello\nWorld' should match\n ... 'Hello'" => function () { Assert::match('%a%', "Hello\nWorld"); }, + "Failed: 'Hello\\n\nWorld' should match\n ... 'Hello'" => function () { Assert::match('%a%', "Hello\nWorld"); }, "Failed: '...xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' should be \n ... '...xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'" => function () { Assert::same(str_repeat('x', 100), str_repeat('x', 120)); }, "Failed: '...xxxxxxxxxxxxxxxxxxxxxxxxxxx****************************************' should be \n ... '...xxxxxxxxxxxxxxxxxxxxxxxxxxx'" => function () { Assert::same(str_repeat('x', 30), str_repeat('x', 30) . str_repeat('*', 40)); }, "Failed: 'xxxxx*****************************************************************...' should be \n ... 'xxxxx'" => function () { Assert::same(str_repeat('x', 5), str_repeat('x', 5) . str_repeat('*', 90)); }, diff --git a/tests/Framework/Dumper.toLine.phpt b/tests/Framework/Dumper.toLine.phpt index 9a7b1930..259b0eb3 100644 --- a/tests/Framework/Dumper.toLine.phpt +++ b/tests/Framework/Dumper.toLine.phpt @@ -21,10 +21,11 @@ Assert::match('NAN', Dumper::toLine(NAN)); Assert::match("''", Dumper::toLine('')); Assert::match("' '", Dumper::toLine(' ')); Assert::match("'0'", Dumper::toLine('0')); -Assert::match('"\\x00"', Dumper::toLine("\x00")); -Assert::match("' '", Dumper::toLine("\t")); -Assert::match('"\\xff"', Dumper::toLine("\xFF")); -Assert::match("'multi\nline'", Dumper::toLine("multi\nline")); +Assert::match("'\e[22m\\x00\e[1m'", Dumper::toLine("\x00")); +Assert::match("'\e[22m\\u{FEFF}\e[1m'", Dumper::toLine("\xEF\xBB\xBF")); // BOM +Assert::match("'\e[22m\\t\t\e[1m'", Dumper::toLine("\t")); +Assert::match("'\e[22m\\xFF\e[1m'", Dumper::toLine("\xFF")); +Assert::match("'multi\e[22m\\n\n\e[1mline'", Dumper::toLine("multi\nline")); Assert::match("'Iñtërnâtiônàlizætiøn'", Dumper::toLine("I\xc3\xb1t\xc3\xabrn\xc3\xa2ti\xc3\xb4n\xc3\xa0liz\xc3\xa6ti\xc3\xb8n")); Assert::match('resource(stream)', Dumper::toLine(fopen(__FILE__, 'r'))); Assert::match('stdClass(#%a%)', Dumper::toLine((object) [1, 2])); diff --git a/tests/Framework/Dumper.toPhp.phpt b/tests/Framework/Dumper.toPhp.phpt index c14c0d85..88fb5ba1 100644 --- a/tests/Framework/Dumper.toPhp.phpt +++ b/tests/Framework/Dumper.toPhp.phpt @@ -29,8 +29,9 @@ Assert::match("''", Dumper::toPhp('')); Assert::match("' '", Dumper::toPhp(' ')); Assert::match("'0'", Dumper::toPhp('0')); Assert::match('"\\x00"', Dumper::toPhp("\x00")); +Assert::match('"\u{FEFF}"', Dumper::toPhp("\xEF\xBB\xBF")); // BOM Assert::match("' '", Dumper::toPhp("\t")); -Assert::match('"\\xff"', Dumper::toPhp("\xFF")); +Assert::match('"\\xFF"', Dumper::toPhp("\xFF")); Assert::match('"multi\nline"', Dumper::toPhp("multi\nline")); Assert::match("'Iñtërnâtiônàlizætiøn'", Dumper::toPhp("I\xc3\xb1t\xc3\xabrn\xc3\xa2ti\xc3\xb4n\xc3\xa0liz\xc3\xa6ti\xc3\xb8n")); Assert::match('[ @@ -41,6 +42,10 @@ Assert::match('[ [1 => 1, 2, 3, 4, 5, 6, 7, \'abcdefgh\'], ]', Dumper::toPhp([1, 'hello', "\r" => [], [1, 2], [1 => 1, 2, 3, 4, 5, 6, 7, 'abcdefgh']])); +Assert::match('\'$"\\\\\'', Dumper::toPhp('$"\\')); +Assert::match('\'$"\\ \x00\'', Dumper::toPhp('$"\\ \x00')); +Assert::match('"\\$\\"\\\\ \x00"', Dumper::toPhp("$\"\\ \x00")); + Assert::match('/* resource stream */', Dumper::toPhp(fopen(__FILE__, 'r'))); Assert::match('(object) /* #%a% */ []', Dumper::toPhp((object) null)); Assert::match("(object) /* #%a% */ [ From a1bc5674facb9e9e124cc9f71e696865e8f7bdd8 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 9 Nov 2021 15:53:47 +0100 Subject: [PATCH 2/5] composer: uses PHPStan ^1.0 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index d9cc1cc8..cee00ec5 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ }, "require-dev": { "ext-simplexml": "*", - "phpstan/phpstan": "^0.12" + "phpstan/phpstan": "^1.0" }, "autoload": { "classmap": ["src/"] From 6b9b34ffe917056d58e5ef3661b2c79e9cb6038f Mon Sep 17 00:00:00 2001 From: David Date: Tue, 23 Nov 2021 00:13:24 +0100 Subject: [PATCH 3/5] Dumper::dumpException() added option to change output file name via AssertException::$outputName --- src/Framework/Assert.php | 14 ++++++++++---- src/Framework/AssertException.php | 2 ++ src/Framework/Dumper.php | 3 +++ 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/Framework/Assert.php b/src/Framework/Assert.php index 17b30e7b..c16a8070 100644 --- a/src/Framework/Assert.php +++ b/src/Framework/Assert.php @@ -481,13 +481,13 @@ public static function matchFile(string $file, $actual, string $description = nu throw new \Exception("Unable to read file '$file'."); } elseif (!is_scalar($actual)) { - self::fail(self::describe('%1 should match %2', $description), $actual, $pattern); + self::fail(self::describe('%1 should match %2', $description), $actual, $pattern, null, basename($file)); } elseif (!self::isMatching($pattern, $actual)) { if (self::$expandPatterns) { [$pattern, $actual] = self::expandMatchingPatterns($pattern, $actual); } - self::fail(self::describe('%1 should match %2', $description), $actual, $pattern); + self::fail(self::describe('%1 should match %2', $description), $actual, $pattern, null, basename($file)); } } @@ -495,9 +495,15 @@ public static function matchFile(string $file, $actual, string $description = nu /** * Assertion that fails. */ - public static function fail(string $message, $actual = null, $expected = null, \Throwable $previous = null): void - { + public static function fail( + string $message, + $actual = null, + $expected = null, + \Throwable $previous = null, + string $outputName = null + ): void { $e = new AssertException($message, $expected, $actual, $previous); + $e->outputName = $outputName; if (self::$onFailure) { (self::$onFailure)($e); } else { diff --git a/src/Framework/AssertException.php b/src/Framework/AssertException.php index 8bb7b411..4c8c9a5d 100644 --- a/src/Framework/AssertException.php +++ b/src/Framework/AssertException.php @@ -21,6 +21,8 @@ class AssertException extends \Exception public $expected; + public $outputName; + public function __construct(string $message, $expected, $actual, \Throwable $previous = null) { diff --git a/src/Framework/Dumper.php b/src/Framework/Dumper.php index c8b7af00..e8250051 100644 --- a/src/Framework/Dumper.php +++ b/src/Framework/Dumper.php @@ -305,6 +305,9 @@ public static function dumpException(\Throwable $e): string if ($e instanceof AssertException) { $expected = $e->expected; $actual = $e->actual; + $testFile = $e->outputName + ? dirname($testFile) . '/' . $e->outputName . '.foo' + : $testFile; if (is_object($expected) || is_array($expected) || (is_string($expected) && strlen($expected) > self::$maxLength) || is_object($actual) || is_array($actual) || (is_string($actual) && (strlen($actual) > self::$maxLength || preg_match('#[\x00-\x1F]#', $actual))) From 8bac4c2338c2af166913cc4e41872704b9ed9dd5 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 24 Nov 2021 00:04:38 +0100 Subject: [PATCH 4/5] interrupt signal is converted to InterruptException and handled by CliTester allows to interrupt watch mode --- src/Runner/CliTester.php | 23 ++++++++++- src/Runner/Runner.php | 80 +++++++++++---------------------------- src/Runner/exceptions.php | 15 ++++++++ src/tester.php | 1 + 4 files changed, 61 insertions(+), 58 deletions(-) create mode 100644 src/Runner/exceptions.php diff --git a/src/Runner/CliTester.php b/src/Runner/CliTester.php index 558d21db..ea5e69c2 100644 --- a/src/Runner/CliTester.php +++ b/src/Runner/CliTester.php @@ -66,6 +66,8 @@ public function run(): ?int $runner->setEnvironmentVariable(Environment::RUNNER, '1'); $runner->setEnvironmentVariable(Environment::COLORS, (string) (int) Environment::$useColors); + $this->installInterruptHandler(); + if ($this->options['--coverage']) { $coverageFile = $this->prepareCodeCoverage($runner); } @@ -360,7 +362,9 @@ private function setupErrors(): void }); set_exception_handler(function (\Throwable $e) { - $this->displayException($e); + if (!$e instanceof InterruptException) { + $this->displayException($e); + } exit(2); }); } @@ -374,4 +378,21 @@ private function displayException(\Throwable $e): void : Dumper::color('white/red', 'Error: ' . $e->getMessage()); echo "\n"; } + + + private function installInterruptHandler(): void + { + if (function_exists('pcntl_signal')) { + pcntl_signal(SIGINT, function (): void { + pcntl_signal(SIGINT, SIG_DFL); + throw new InterruptException; + }); + pcntl_async_signals(true); + + } elseif (function_exists('sapi_windows_set_ctrl_handler') && PHP_SAPI === 'cli') { + sapi_windows_set_ctrl_handler(function (): void { + throw new InterruptException; + }); + } + } } diff --git a/src/Runner/Runner.php b/src/Runner/Runner.php index 77575ed6..49c0dc1e 100644 --- a/src/Runner/Runner.php +++ b/src/Runner/Runner.php @@ -125,36 +125,37 @@ public function run(): bool $threads = range(1, $this->threadCount); - $this->installInterruptHandler(); $async = $this->threadCount > 1 && count($this->jobs) > 1; - while (($this->jobs || $running) && !$this->isInterrupted()) { - while ($threads && $this->jobs) { - $running[] = $job = array_shift($this->jobs); - $job->setEnvironmentVariable(Environment::THREAD, (string) array_shift($threads)); - $job->run($async ? $job::RUN_ASYNC : 0); - } - - if ($async) { - usleep(Job::RUN_USLEEP); // stream_select() doesn't work with proc_open() - } + try { + while (($this->jobs || $running) && !$this->interrupted) { + while ($threads && $this->jobs) { + $running[] = $job = array_shift($this->jobs); + $job->setEnvironmentVariable(Environment::THREAD, (string) array_shift($threads)); + $job->run($async ? $job::RUN_ASYNC : 0); + } - foreach ($running as $key => $job) { - if ($this->isInterrupted()) { - break 2; + if ($async) { + usleep(Job::RUN_USLEEP); // stream_select() doesn't work with proc_open() } - if (!$job->isRunning()) { - $threads[] = $job->getEnvironmentVariable(Environment::THREAD); - $this->testHandler->assess($job); - unset($running[$key]); + foreach ($running as $key => $job) { + if ($this->interrupted) { + break 2; + } + + if (!$job->isRunning()) { + $threads[] = $job->getEnvironmentVariable(Environment::THREAD); + $this->testHandler->assess($job); + unset($running[$key]); + } } } - } - $this->removeInterruptHandler(); - foreach ($this->outputHandlers as $handler) { - $handler->end(); + } finally { + foreach ($this->outputHandlers as $handler) { + $handler->end(); + } } return $this->result; @@ -235,41 +236,6 @@ public function getInterpreter(): PhpInterpreter } - private function installInterruptHandler(): void - { - if (function_exists('pcntl_signal')) { - pcntl_signal(SIGINT, function (): void { - pcntl_signal(SIGINT, SIG_DFL); - $this->interrupted = true; - }); - } elseif (function_exists('sapi_windows_set_ctrl_handler') && PHP_SAPI === 'cli') { - sapi_windows_set_ctrl_handler(function () { - $this->interrupted = true; - }); - } - } - - - private function removeInterruptHandler(): void - { - if (function_exists('pcntl_signal')) { - pcntl_signal(SIGINT, SIG_DFL); - } elseif (function_exists('sapi_windows_set_ctrl_handler') && PHP_SAPI === 'cli') { - sapi_windows_set_ctrl_handler(null); - } - } - - - private function isInterrupted(): bool - { - if (function_exists('pcntl_signal_dispatch')) { - pcntl_signal_dispatch(); - } - - return $this->interrupted; - } - - private function getLastResult(Test $test): int { $signature = $test->getSignature(); diff --git a/src/Runner/exceptions.php b/src/Runner/exceptions.php new file mode 100644 index 00000000..5d5a52f4 --- /dev/null +++ b/src/Runner/exceptions.php @@ -0,0 +1,15 @@ + Date: Tue, 7 Dec 2021 21:09:08 +0300 Subject: [PATCH 5/5] Add new assert json tests --- tests/Framework/Assert.json.phpt | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 tests/Framework/Assert.json.phpt diff --git a/tests/Framework/Assert.json.phpt b/tests/Framework/Assert.json.phpt new file mode 100644 index 00000000..70470258 --- /dev/null +++ b/tests/Framework/Assert.json.phpt @@ -0,0 +1,25 @@ +