diff --git a/bridge/rector/FEATURE_PARITY.md b/bridge/rector/FEATURE_PARITY.md index ee441d48..ce108d8b 100644 --- a/bridge/rector/FEATURE_PARITY.md +++ b/bridge/rector/FEATURE_PARITY.md @@ -158,3 +158,6 @@ both reverse directions produce it — a `markTestSkipped('literal')` opening a (`MarkTestSkippedToSkipAttributeRector`) and Pest's `->skip('reason')` modifier. What stays a `SkipTest` throw is exactly what cannot be declared: a guarded call, one deeper in the body, or a message no attribute argument can hold. + +String assertions preserve named binding, weak/strict coercion, argument evaluation order and void +returns through a typed inline closure when needed. Other named assertion shapes remain manual. diff --git a/bridge/rector/README.md b/bridge/rector/README.md index 2062365a..054c3397 100644 --- a/bridge/rector/README.md +++ b/bridge/rector/README.md @@ -72,6 +72,14 @@ Testo's comparison assertions are `(actual, expected)`; PHPUnit's are `(expected Both `AssertCall*` rules swap the first two arguments accordingly — getting this wrong would silently invert every comparison, so it is covered by fixtures. +### String assertion contracts + +String assertion calls with effectful or potentially coercible arguments, or a used result, use an inline +typed closure with PHPUnit's parameter names. It keeps source evaluation order, weak/strict string +coercion, message validation and the original `void` return. Simple standalone calls with native string +types keep the shorter fluent form, normalising supported named arguments positionally. Other named +assertion shapes remain for manual migration. + ## Why Testo → PHPUnit Testo is self-hosted: the engine that discovers and runs tests is the same code Infection diff --git a/bridge/rector/src/PhpunitToTesto/AssertCallToTestoRector.php b/bridge/rector/src/PhpunitToTesto/AssertCallToTestoRector.php index 5f1ed5e8..1bcd29b7 100644 --- a/bridge/rector/src/PhpunitToTesto/AssertCallToTestoRector.php +++ b/bridge/rector/src/PhpunitToTesto/AssertCallToTestoRector.php @@ -113,6 +113,13 @@ public function refactor(Node $node): ?Node return null; } + # Keep unsupported argument bindings visible for manual migration. + foreach ($node->args as $argument) { + if (!$argument instanceof Arg || $argument->unpack || $argument->name !== null) { + return null; + } + } + if (isset(self::NOT_BOOL[$method])) { return $this->notBool(self::NOT_BOOL[$method], $node->args); } diff --git a/bridge/rector/src/PhpunitToTesto/AssertCallToTestoRector/unsupported_named_unchanged.php.inc b/bridge/rector/src/PhpunitToTesto/AssertCallToTestoRector/unsupported_named_unchanged.php.inc new file mode 100644 index 00000000..75255939 --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/AssertCallToTestoRector/unsupported_named_unchanged.php.inc @@ -0,0 +1,11 @@ +assertSame(expected: 1, actual: 1); + $this->assertTrue(actual: true); + $this->assertSame(...$arguments); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/TODO.md b/bridge/rector/src/PhpunitToTesto/TODO.md index 5fb153de..11bbb924 100644 --- a/bridge/rector/src/PhpunitToTesto/TODO.md +++ b/bridge/rector/src/PhpunitToTesto/TODO.md @@ -155,8 +155,11 @@ Mocks are out of this set: they convert through `phpunit-to-double` or `phpunit- error check, so a match that fails at runtime (backtrack or JIT stack limit) failed `assertRegExp` and passed `assertNotRegExp`; `matchesRegex()`/`notMatchesRegex()` throw `\InvalidArgumentException` there, as PHPUnit 10+ throws a framework exception. The string matchers - also need a string subject: outside `strict_types` PHPUnit's `string` parameter coerces an int, - float or `Stringable`, which `Assert::string()` rejects. + preserve the caller's string boundary through an inline typed void closure when native string types, + simple expressions and an unused result do not justify the shorter chain. Safe named string + arguments are normalised positionally; other named strings bind to + PHPUnit's source parameter names; argument evaluation order, Stringable coercion and message validation + stay at the call boundary. Other named assertion shapes remain manual. **Whitespace residual:** PHPUnit runs `preg_replace('/\s+/u', ' ')` and `trim()`, while `ignoringWhitespace(lineBreaks: true)` also counts the `\h`/`\v` characters outside `\s` (U+180E) diff --git a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector.php b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector.php index c419e821..27136bda 100644 --- a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector.php +++ b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector.php @@ -11,6 +11,7 @@ use PhpParser\Node\Expr\BinaryOp\Concat; use PhpParser\Node\Expr\BooleanNot; use PhpParser\Node\Expr\ClassConstFetch; +use PhpParser\Node\Expr\Closure; use PhpParser\Node\Expr\ConstFetch; use PhpParser\Node\Expr\Empty_; use PhpParser\Node\Expr\FuncCall; @@ -21,8 +22,11 @@ use PhpParser\Node\Identifier; use PhpParser\Node\Name; use PhpParser\Node\Name\FullyQualified; +use PhpParser\Node\Param; use PhpParser\Node\Scalar\MagicConst; use PhpParser\Node\Scalar\String_; +use PhpParser\Node\Stmt\Expression; +use PhpParser\NodeFinder; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ReflectionProvider; use PHPStan\Type\FloatType; @@ -39,8 +43,10 @@ * Rewrites the PHPUnit assertions that map onto a Testo **typed head + matcher** chain rather than a * flat facade call — the shape {@see AssertCallToTestoRector} deliberately does not handle. * - * The subject moves from an argument position to the head argument (still evaluated exactly once, - * so no hoisting is needed) and the remaining argument becomes the matcher argument: + * The subject moves from an argument position to the head argument. For string assertions, the + * short form requires a standalone call with simple expressions and native string types. Otherwise a typed + * void closure keeps source parameter names, evaluation order and weak/strict coercion, including + * the message. PHPDoc-only string types do not justify bypassing the parameter boundary. * * - $this->assertGreaterThan($e, $a) → \Testo\Assert::numeric($a)->greaterThan($e) * - $this->assertGreaterThanOrEqual(...) → …->greaterThanOrEqual(…) @@ -114,7 +120,8 @@ * either, so one on `assertJson` is dropped as well. * * Recognises the same call spellings as {@see AssertCallToTestoRector}, `PHPUnit\Framework\assert*()` - * functions included, and likewise only inside a class. + * functions included, and likewise only inside a class. Unsupported named argument shapes outside + * the string mappings remain for manual migration. */ #[TestRectorFixtures('TypedAssertCallToTestoRector')] final class TypedAssertCallToTestoRector extends AbstractRector @@ -142,22 +149,6 @@ final class TypedAssertCallToTestoRector extends AbstractRector 'assertArrayNotHasKey' => 'doesNotHaveKeys', ]; - /** - * String assertions with a comparison mode → `Assert::string($subject)->()->()`. - * `$message` is preserved. PHPUnit's whitespace mode turns every run of whitespace, line breaks - * included, into one space and trims, which is `ignoringWhitespace(lineBreaks: true)`. - * - * @var array - */ - private const STRING_MODIFIED = [ - 'assertStringContainsStringIgnoringCase' => ['ignoringCase', 'contains'], - 'assertStringNotContainsStringIgnoringCase' => ['ignoringCase', 'notContains'], - 'assertStringContainsStringIgnoringLineEndings' => ['ignoringLineEndings', 'contains'], - 'assertStringEqualsStringIgnoringLineEndings' => ['ignoringLineEndings', 'same'], - 'assertStringEqualsStringIgnoringWhitespace' => ['ignoringWhitespace', 'same'], - 'assertStringNotEqualsStringIgnoringWhitespace' => ['ignoringWhitespace', 'notSame'], - ]; - /** * Case-insensitive equality → `Assert::string($actual)->ignoringCase()->same|notSame($expected)`, * only when both sides are strings: PHPUnit also compares other scalars and arrays, recursively. @@ -315,23 +306,22 @@ public function refactor(Node $node): ?Node return null; } + $string = $this->stringAssertion($method); + if ($string !== null) { + return $this->stringChain($node, ...$string); + } + + # Other mappings do not yet model PHPUnit's named parameter contracts. + foreach ($node->args as $argument) { + if (!$argument instanceof Arg || $argument->unpack || $argument->name !== null) { + return null; + } + } + return match (true) { isset(self::NUMERIC[$method]) => $this->typedChain('numeric', self::NUMERIC[$method], $node->args, keepMessage: true), isset(self::ARRAY_KEY[$method]) => $this->typedChain('array', self::ARRAY_KEY[$method], $node->args, keepMessage: false), $method === 'assertEqualsCanonicalizing' => $this->typedChain('array', 'sameElementsAs', $node->args, keepMessage: true), - $method === 'assertStringStartsWith' => $this->typedChain('string', 'startsWith', $node->args, keepMessage: true), - $method === 'assertStringEndsWith' => $this->typedChain('string', 'endsWith', $node->args, keepMessage: true), - $method === 'assertStringStartsNotWith' => $this->typedChain('string', 'notStartsWith', $node->args, keepMessage: true), - $method === 'assertStringEndsNotWith' => $this->typedChain('string', 'notEndsWith', $node->args, keepMessage: true), - $method === 'assertStringContainsString' => $this->typedChain('string', 'contains', $node->args, keepMessage: true), - $method === 'assertStringNotContainsString' => $this->typedChain('string', 'notContains', $node->args, keepMessage: true), - isset(self::STRING_MODIFIED[$method]) => $this->typedChain( - 'string', - self::STRING_MODIFIED[$method][1], - $node->args, - keepMessage: true, - modifier: self::STRING_MODIFIED[$method][0], - ), isset(self::EQUALS_IGNORING_CASE[$method]) && $this->areStrings($node->args) => $this->typedChain( 'string', self::EQUALS_IGNORING_CASE[$method], @@ -339,10 +329,6 @@ public function refactor(Node $node): ?Node keepMessage: true, modifier: 'ignoringCase', ), - $method === 'assertMatchesRegularExpression', $method === 'assertRegExp' - => $this->typedChain('string', 'matchesRegex', $node->args, keepMessage: true), - $method === 'assertDoesNotMatchRegularExpression', $method === 'assertNotRegExp' - => $this->typedChain('string', 'notMatchesRegex', $node->args, keepMessage: true), $method === 'assertNotContains' => $this->typedChain('iterable', 'notContains', $node->args, keepMessage: true), $method === 'assertObjectHasProperty' => $this->typedChain('object', 'hasProperty', $node->args, keepMessage: true), $method === 'assertIsList' => $this->subjectChain('array', 'isList', [], $node->args), @@ -363,6 +349,95 @@ public function refactor(Node $node): ?Node }; } + /** + * @return array{non-empty-string, non-empty-string, non-empty-string, non-empty-string|null}|null + */ + private function stringAssertion(string $method): ?array + { + return match ($method) { + 'assertStringStartsWith' => ['prefix', 'string', 'startsWith', null], + 'assertStringEndsWith' => ['suffix', 'string', 'endsWith', null], + 'assertStringStartsNotWith' => ['prefix', 'string', 'notStartsWith', null], + 'assertStringEndsNotWith' => ['suffix', 'string', 'notEndsWith', null], + 'assertStringContainsString' => ['needle', 'haystack', 'contains', null], + 'assertStringNotContainsString' => ['needle', 'haystack', 'notContains', null], + 'assertStringContainsStringIgnoringCase' => ['needle', 'haystack', 'contains', 'ignoringCase'], + 'assertStringNotContainsStringIgnoringCase' => ['needle', 'haystack', 'notContains', 'ignoringCase'], + 'assertStringContainsStringIgnoringLineEndings' => ['needle', 'haystack', 'contains', 'ignoringLineEndings'], + 'assertStringEqualsStringIgnoringLineEndings' => ['expected', 'actual', 'same', 'ignoringLineEndings'], + 'assertStringEqualsStringIgnoringWhitespace' => ['expected', 'actual', 'same', 'ignoringWhitespace'], + 'assertStringNotEqualsStringIgnoringWhitespace' => ['expected', 'actual', 'notSame', 'ignoringWhitespace'], + 'assertMatchesRegularExpression', 'assertRegExp' => ['pattern', 'string', 'matchesRegex', null], + 'assertDoesNotMatchRegularExpression', 'assertNotRegExp' => ['pattern', 'string', 'notMatchesRegex', null], + default => null, + }; + } + + /** + * Keep the native string parameter boundary and lexical evaluation order when a direct + * head/matcher split would change them. Named arguments bind to the source parameter names. + * + * @param MethodCall|StaticCall|FuncCall $node + */ + private function stringChain(Node $node, string $first, string $second, string $matcher, ?string $modifier): ?Expr + { + $args = $node->args; + $bound = []; + $named = false; + $parameters = [$first, $second, 'message']; + foreach ($args as $index => $argument) { + if (!$argument instanceof Arg || $argument->unpack || $argument->byRef + || ($named && $argument->name === null) + ) { + return null; + } + $named = $argument->name !== null; + $name = $argument->name?->toString() ?? ($parameters[$index] ?? null); + if ($name === null || !\in_array($name, $parameters, true) || isset($bound[$name])) { + return null; + } + $bound[$name] = $argument; + } + if (!isset($bound[$first], $bound[$second])) { + return null; + } + + $direct = (new NodeFinder())->findFirst( + $this->getFile()->getNewStmts(), + static fn(Node $statement): bool => $statement instanceof Expression && $statement->expr === $node, + ) !== null; + foreach ($args as $argument) { + $direct = $direct + && ($argument->value instanceof String_ || ($argument->value instanceof Variable && \is_string($argument->value->name))) + && $this->getNativeType($argument->value)->isString()->yes(); + } + if ($direct) { + $ordered = []; + foreach ($parameters as $name) { + if (isset($bound[$name])) { + $ordered[] = new Arg($bound[$name]->value); + } + } + return $this->typedChain('string', $matcher, $ordered, keepMessage: true, modifier: $modifier); + } + + $params = []; + $inner = []; + foreach ($parameters as $index => $name) { + $variable = new Variable($name); + $params[] = new Param($variable, default: $index === 2 ? new String_('') : null, type: new Identifier('string')); + $inner[] = new Arg($variable); + } + $chain = $this->typedChain('string', $matcher, $inner, keepMessage: true, modifier: $modifier); + \assert($chain instanceof MethodCall); + return new FuncCall(new Closure([ + 'static' => true, + 'params' => $params, + 'returnType' => new Identifier('void'), + 'stmts' => [new Expression($chain)], + ]), $args); + } + /** * `assertIsString($subject)` → `Assert::string($subject)`. A type head takes no message, so a call * that carries one falls back to the predicate form, which keeps it. diff --git a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/contains_list_property.php.inc b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/contains_list_property.php.inc index 84068281..0f2f910c 100644 --- a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/contains_list_property.php.inc +++ b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/contains_list_property.php.inc @@ -12,6 +12,12 @@ class ContainsListPropertyTest $this->assertIsList($rows); $this->assertIsList($rows, 'sequential keys'); } + + public function namedArguments(string $needle, string $haystack): void + { + $this->assertStringContainsString(needle: 'needle', haystack: 'haystack'); + $this->assertStringContainsString(haystack: $haystack, needle: $needle); + } } ----- contains('needle'); - \Testo\Assert::string($output)->notContains('error', 'no errors'); + (static function (string $needle, string $haystack, string $message = ''): void { + \Testo\Assert::string($haystack)->contains($needle, $message); + })('needle', $haystack); + (static function (string $needle, string $haystack, string $message = ''): void { + \Testo\Assert::string($haystack)->notContains($needle, $message); + })('error', $output, 'no errors'); \Testo\Assert::iterable($items)->notContains($removed); \Testo\Assert::iterable($letters)->notContains('x', 'x is gone'); \Testo\Assert::object($entity)->hasProperty('id', 'has an id'); \Testo\Assert::array($rows)->isList(); \Testo\Assert::array($rows)->isList('sequential keys'); } + + public function namedArguments(string $needle, string $haystack): void + { + \Testo\Assert::string('haystack')->contains('needle'); + \Testo\Assert::string($haystack)->contains($needle); + } } diff --git a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/regular_expression.php.inc b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/regular_expression.php.inc index 7a16a552..6f4a726f 100644 --- a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/regular_expression.php.inc +++ b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/regular_expression.php.inc @@ -17,9 +17,17 @@ class RegularExpressionAssertTest { public function test() { - \Testo\Assert::string($date)->matchesRegex('/^\d{4}-\d{2}-\d{2}$/'); - \Testo\Assert::string($date)->notMatchesRegex('/\s$/', 'no trailing space'); - \Testo\Assert::string($name)->matchesRegex('/^a/i', 'starts with a'); - \Testo\Assert::string($name)->notMatchesRegex('/^b/'); + (static function (string $pattern, string $string, string $message = ''): void { + \Testo\Assert::string($string)->matchesRegex($pattern, $message); + })('/^\d{4}-\d{2}-\d{2}$/', $date); + (static function (string $pattern, string $string, string $message = ''): void { + \Testo\Assert::string($string)->notMatchesRegex($pattern, $message); + })('/\s$/', $date, 'no trailing space'); + (static function (string $pattern, string $string, string $message = ''): void { + \Testo\Assert::string($string)->matchesRegex($pattern, $message); + })('/^a/i', $name, 'starts with a'); + (static function (string $pattern, string $string, string $message = ''): void { + \Testo\Assert::string($string)->notMatchesRegex($pattern, $message); + })('/^b/', $name); } } diff --git a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/regular_expression_function_call.php.inc b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/regular_expression_function_call.php.inc index 8fc0cf39..d45c6828 100644 --- a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/regular_expression_function_call.php.inc +++ b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/regular_expression_function_call.php.inc @@ -25,7 +25,11 @@ class RegularExpressionFunctionCallTest { public function test() { - \Testo\Assert::string($id)->matchesRegex('/^id-\d+$/', 'id format'); - \Testo\Assert::string($id)->notMatchesRegex('/\s/'); + (static function (string $pattern, string $string, string $message = ''): void { + \Testo\Assert::string($string)->matchesRegex($pattern, $message); + })('/^id-\d+$/', $id, 'id format'); + (static function (string $pattern, string $string, string $message = ''): void { + \Testo\Assert::string($string)->notMatchesRegex($pattern, $message); + })('/\s/', $id); } } diff --git a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/string_not_starts_ends_with.php.inc b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/string_not_starts_ends_with.php.inc index 5ea3a83b..89b29e31 100644 --- a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/string_not_starts_ends_with.php.inc +++ b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/string_not_starts_ends_with.php.inc @@ -15,7 +15,11 @@ class StringNotStartsEndsWithTest { public function test() { - \Testo\Assert::string($path)->notStartsWith('/tmp'); - \Testo\Assert::string($path)->notEndsWith('.md', 'not markdown'); + (static function (string $prefix, string $string, string $message = ''): void { + \Testo\Assert::string($string)->notStartsWith($prefix, $message); + })('/tmp', $path); + (static function (string $suffix, string $string, string $message = ''): void { + \Testo\Assert::string($string)->notEndsWith($suffix, $message); + })('.md', $path, 'not markdown'); } } diff --git a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/type_checks.php.inc b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/type_checks.php.inc index 5508cb3d..6f990ec1 100644 --- a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/type_checks.php.inc +++ b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/type_checks.php.inc @@ -37,7 +37,11 @@ class TypeChecksTest \Testo\Assert::false(\is_int($value), 'not an int'); \Testo\Assert::true(\file_exists($path)); \Testo\Assert::false(\is_dir($dir)); - \Testo\Assert::string($message)->startsWith('Name must'); - \Testo\Assert::string($file)->endsWith('.json', 'suffix'); + (static function (string $prefix, string $string, string $message = ''): void { + \Testo\Assert::string($string)->startsWith($prefix, $message); + })('Name must', $message); + (static function (string $suffix, string $string, string $message = ''): void { + \Testo\Assert::string($string)->endsWith($suffix, $message); + })('.json', $file, 'suffix'); } } diff --git a/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/unsupported_named_unchanged.php.inc b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/unsupported_named_unchanged.php.inc new file mode 100644 index 00000000..f0680333 --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector/unsupported_named_unchanged.php.inc @@ -0,0 +1,14 @@ +assertStringContainsString(wrong: 'x', haystack: 'xyz'); + $this->assertStringContainsString(needle: 'x', haystack: 'xyz', wrong: 'context'); + $this->assertStringContainsString(...$arguments); + $this->assertStringContainsString(...); + $this->assertIsString(actual: 'xyz'); + $this->assertGreaterThan(expected: 0, actual: 1); + } +} diff --git a/bridge/rector/src/Testing/Internal/RectorRunner.php b/bridge/rector/src/Testing/Internal/RectorRunner.php index f97fa263..3603e34d 100644 --- a/bridge/rector/src/Testing/Internal/RectorRunner.php +++ b/bridge/rector/src/Testing/Internal/RectorRunner.php @@ -92,6 +92,16 @@ public function assertConverts(Path $fixturePath): void $this->channel->debug("# Input:\n$input"); $this->channel->debug("# Expected:\n$expected"); + $changed = $this->convert($input, $fixturePath->name()); + + Assert::same($changed, $expected, \sprintf('Fixture "%s" was not converted as expected', $fixturePath->name())); + } + + /** + * Run the configured rules and return executable PHP for runtime regression tests. + */ + public function convert(string $input, string $name = 'input'): string + { $inputFile = $this->writeTempFile($input); try { @@ -121,7 +131,7 @@ public function assertConverts(Path $fixturePath): void throw new \RuntimeException(\sprintf( "Rector failed on fixture \"%s\":\n%s", - $fixturePath->name(), + $name, \implode("\n", \array_map(static fn(SystemError $error): string => $error->getMessage(), $errors)), ), previous: $crash); } @@ -131,7 +141,7 @@ public function assertConverts(Path $fixturePath): void @\unlink($inputFile); } - Assert::same($changed, $expected, \sprintf('Fixture "%s" was not converted as expected', $fixturePath->name())); + return $changed; } /** diff --git a/bridge/rector/tests/Feature/Fixture/ConvertedRuntime.php b/bridge/rector/tests/Feature/Fixture/ConvertedRuntime.php new file mode 100644 index 00000000..b8325685 --- /dev/null +++ b/bridge/rector/tests/Feature/Fixture/ConvertedRuntime.php @@ -0,0 +1,73 @@ +}> + */ + public static function run(string $source): array + { + $root = \dirname(__DIR__, 5); + $directory = \sys_get_temp_dir() . '/testo-converted-' . \bin2hex(\random_bytes(8)); + \mkdir($directory); + $input = $directory . '/converted.php'; + $config = $directory . '/testo.php'; + $junit = $directory . '/junit.xml'; + $stderr = $directory . '/stderr.txt'; + \file_put_contents($input, $source); + \file_put_contents($config, \sprintf(<<<'PHP' + ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['file', $stderr, 'w']], + $pipes, + $root, + ); + if (!\is_resource($process)) { + throw new \RuntimeException('Unable to start the converted test suite'); + } + \fclose($pipes[0]); + $stdout = (string) \stream_get_contents($pipes[1]); + \fclose($pipes[1]); + $exit = \proc_close($process); + $report = \json_decode($stdout, true); + if (!\in_array($exit, [0, 1], true) || !\is_array($report) || !isset($report['status']) || !\is_file($junit)) { + throw new \RuntimeException("Converted suite exited $exit without a report: $stdout\n" . \file_get_contents($stderr)); + } + $failures = \array_column($report['failures'], null, 'test'); + $results = []; + foreach ((new \SimpleXMLElement((string) \file_get_contents($junit)))->xpath('//testcase') as $case) { + $name = (string) $case['classname'] . '::' . (string) $case['name']; + $failure = $failures[$name] ?? null; + $results[$name] = [$failure['status'] ?? 'passed', (int) $case['assertions']]; + if (($failure['status'] ?? null) === 'error') { + $results[$name][] = $failure['exception']; + } + } + return $results; + } finally { + foreach ([$input, $config, $junit, $stderr] as $file) { + \is_file($file) and \unlink($file); + } + \rmdir($directory); + } + } +} diff --git a/bridge/rector/tests/Feature/Fixture/runtime_strict.php.inc b/bridge/rector/tests/Feature/Fixture/runtime_strict.php.inc new file mode 100644 index 00000000..3ebffc37 --- /dev/null +++ b/bridge/rector/tests/Feature/Fixture/runtime_strict.php.inc @@ -0,0 +1,28 @@ +assertStringContainsString(haystack: 'xyz', needle: 'x'); + } + + public function testIntegerMustError(): void + { + $this->assertStringContainsString('2', 123); + } + + public function testStringableMustError(): void + { + $this->assertStringContainsString('x', new class implements \Stringable { + public function __toString(): string { return 'xyz'; } + }); + } + + public function testInvalidMessageMustError(): void + { + $this->assertStringContainsString('x', 'xyz', message: 123); + } +} diff --git a/bridge/rector/tests/Feature/Fixture/runtime_weak.php.inc b/bridge/rector/tests/Feature/Fixture/runtime_weak.php.inc new file mode 100644 index 00000000..c9a10714 --- /dev/null +++ b/bridge/rector/tests/Feature/Fixture/runtime_weak.php.inc @@ -0,0 +1,103 @@ +assertStringContainsString(needle: 'x', haystack: 'xyz'); + $needle = 'x'; + $haystack = 'xyz'; + $this->assertStringContainsString(haystack: $haystack, needle: $needle); + } + + public function testNamedReversed(): void + { + $this->trace = []; + $this->assertStringContainsString(haystack: $this->mark('haystack', 'xyz'), needle: $this->mark('needle', 'x'), message: $this->mark('message', 'named')); + \Testo\Assert::same($this->trace, ['haystack', 'needle', 'message']); + } + + public function testWeakScalar(): void + { + $this->assertStringContainsString('2', 123); + } + + public function testPhpDocCannotReplaceNativeType(): void + { + $this->annotatedHaystack(123); + } + + public function testWeakStringable(): void + { + $this->trace = []; + $this->assertStringContainsString('x', new class(fn() => $this->mark('cast', 'xyz')) implements \Stringable { + public function __construct(private \Closure $value) {} + public function __toString(): string { return ($this->value)(); } + }); + \Testo\Assert::same($this->trace, ['cast']); + } + + public function testPositionalAndNamedMessage(): void + { + $this->assertStringContainsString('x', 'xyz', message: 'present'); + $this->assertStringStartsWith(message: 'context', prefix: 'x', string: 'xyz'); + } + + public function testFunctionNamedReversed(): void + { + \PHPUnit\Framework\assertStringContainsString(haystack: 'xyz', needle: 'x'); + } + + public function testSelfAndStatic(): void + { + self::assertStringContainsString('2', 123); + static::assertStringContainsString(haystack: 'xyz', needle: 'x'); + } + + public function testWeakMessageScalar(): void + { + $this->assertStringContainsString('x', 'xyz', message: 123); + } + + public function testPositionalEvaluationOrder(): void + { + $this->trace = []; + $this->assertStringContainsString($this->mark('needle', 'x'), $this->mark('haystack', 'xyz'), $this->mark('message', 'present')); + \Testo\Assert::same($this->trace, ['needle', 'haystack', 'message']); + } + + public function testNecessaryFailure(): void + { + $this->assertStringContainsString('missing', 'xyz'); + } + + public function testReturnRemainsNull(): void + { + \Testo\Assert::same($this->assertStringContainsString(needle: 'x', haystack: 'xyz'), null); + } + + public function testPositionalReturnRemainsNull(): void + { + \Testo\Assert::same($this->assertStringContainsString('x', 'xyz'), null); + \Testo\Assert::same($this->assertionReturn(), null); + } + + private function mark(string $label, string $value): string + { + $this->trace[] = $label; + return $value; + } + + /** @param string $haystack */ + private function annotatedHaystack($haystack): void + { + $this->assertStringContainsString('2', $haystack); + } + + private function assertionReturn(): mixed + { + return $this->assertStringContainsString('x', 'xyz'); + } +} diff --git a/bridge/rector/tests/Feature/TypedAssertionRuntimeTest.php b/bridge/rector/tests/Feature/TypedAssertionRuntimeTest.php new file mode 100644 index 00000000..7724ded7 --- /dev/null +++ b/bridge/rector/tests/Feature/TypedAssertionRuntimeTest.php @@ -0,0 +1,61 @@ +assertFixture('runtime_weak', [ + 'WeakStringRuntimeContracts::testNamed' => ['passed', 2], + 'WeakStringRuntimeContracts::testNamedReversed' => ['passed', 2], + 'WeakStringRuntimeContracts::testWeakScalar' => ['passed', 1], + 'WeakStringRuntimeContracts::testPhpDocCannotReplaceNativeType' => ['passed', 1], + 'WeakStringRuntimeContracts::testWeakStringable' => ['passed', 2], + 'WeakStringRuntimeContracts::testPositionalAndNamedMessage' => ['passed', 2], + 'WeakStringRuntimeContracts::testFunctionNamedReversed' => ['passed', 1], + 'WeakStringRuntimeContracts::testSelfAndStatic' => ['passed', 2], + 'WeakStringRuntimeContracts::testWeakMessageScalar' => ['passed', 1], + 'WeakStringRuntimeContracts::testPositionalEvaluationOrder' => ['passed', 2], + 'WeakStringRuntimeContracts::testNecessaryFailure' => ['failed', 1], + 'WeakStringRuntimeContracts::testReturnRemainsNull' => ['passed', 2], + 'WeakStringRuntimeContracts::testPositionalReturnRemainsNull' => ['passed', 4], + ]); + } + + public function strictCallsKeepTypeErrorsBeforeAnyAssertion(): void + { + $this->assertFixture('runtime_strict', [ + 'StrictStringRuntimeContracts::testValidNamed' => ['passed', 1], + 'StrictStringRuntimeContracts::testIntegerMustError' => ['error', 0, \TypeError::class], + 'StrictStringRuntimeContracts::testStringableMustError' => ['error', 0, \TypeError::class], + 'StrictStringRuntimeContracts::testInvalidMessageMustError' => ['error', 0, \TypeError::class], + ]); + } + + /** + * @param array}> $expected + */ + private function assertFixture(string $name, array $expected): void + { + $input = (string) \file_get_contents(__DIR__ . '/Fixture/' . $name . '.php.inc'); + $runner = new RectorRunner(new RecordingMessenger(), [], [TestoRectorSetList::PHPUNIT_TO_TESTO]); + $converted = $runner->convert($input); + $actual = ConvertedRuntime::run($converted); + \ksort($actual); + \ksort($expected); + Assert::same($actual, $expected); + } +} diff --git a/bridge/rector/tests/suites.php b/bridge/rector/tests/suites.php index 0b30e559..ea9343f4 100644 --- a/bridge/rector/tests/suites.php +++ b/bridge/rector/tests/suites.php @@ -13,6 +13,7 @@ * `#[TestRectorFixtures]` and runs their co-located `*.php.inc` fixtures as data sets. * - `Bridge/Rector/Unit` holds the plain unit tests for the harness itself (path resolution, * the containment guard, the attribute). + * - `Bridge/Rector/Feature` executes converted tests through the Testo pipeline. */ return [ new SuiteConfig( @@ -28,4 +29,8 @@ include: [__DIR__ . '/Unit'], ), ), + new SuiteConfig( + name: 'Bridge/Rector/Feature', + location: new FinderConfig(include: [__DIR__ . '/Feature']), + ), ]; diff --git a/skills/testo-migrate-from-phpunit/references/phpunit-to-testo-map.md b/skills/testo-migrate-from-phpunit/references/phpunit-to-testo-map.md index 25f43595..c6f8b3fb 100644 --- a/skills/testo-migrate-from-phpunit/references/phpunit-to-testo-map.md +++ b/skills/testo-migrate-from-phpunit/references/phpunit-to-testo-map.md @@ -16,6 +16,12 @@ the assertion **argument order flips** (see the pitfalls), and discovery is attr > The left column is mechanical; the right column is why **every** migration ends with an AI/human > pass and a test-count check against the PHPUnit run. +Simple standalone string assertions with native string arguments use a short chain, normalising +supported names positionally. Effectful/coercible arguments or a used result need a typed `void` +closure with source parameter names to retain binding, evaluation order and weak/strict coercion. +Other named assertion shapes remain manual. Do not simplify those closures to casts or reorder their +arguments: both can change TypeErrors or side effects. + ## Translation table | PHPUnit | Testo |