From 6a6800eee6432350680733d10b95e0da0f49f4a1 Mon Sep 17 00:00:00 2001 From: Ichi Date: Thu, 15 Oct 2026 12:34:31 +0500 Subject: [PATCH 1/4] fix(rector): preserve PHPUnit exception expectation contracts Handle exact-empty and last-wins messages, normalize named expectation arguments, and keep argument snapshots across assignments. Add transformed runtime controls and document independent dynamic regex limits. Refs #389 Assisted-By: GPT-6 --- bridge/rector/FEATURE_PARITY.md | 2 +- .../ExpectExceptionToTestoRector.php | 173 ++++++++++++-- .../fold_across_assignment.php.inc | 3 +- ...dent_dynamic_constraints_unchanged.php.inc | 19 ++ .../runtime_contracts.php.inc | 222 ++++++++++++++++++ .../unsupported_arguments_unchanged.php.inc | 12 + bridge/rector/src/PhpunitToTesto/TODO.md | 21 +- .../src/Testing/Internal/RectorRunner.php | 14 +- .../Unit/ExceptionExpectationRuntimeTest.php | 43 ++++ .../tests/Unit/Fixture/ConvertedRuntime.php | 54 +++++ .../references/phpunit-to-testo-map.md | 2 +- 11 files changed, 528 insertions(+), 37 deletions(-) create mode 100644 bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/independent_dynamic_constraints_unchanged.php.inc create mode 100644 bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/runtime_contracts.php.inc create mode 100644 bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/unsupported_arguments_unchanged.php.inc create mode 100644 bridge/rector/tests/Unit/ExceptionExpectationRuntimeTest.php create mode 100644 bridge/rector/tests/Unit/Fixture/ConvertedRuntime.php diff --git a/bridge/rector/FEATURE_PARITY.md b/bridge/rector/FEATURE_PARITY.md index ee441d48..abe86c11 100644 --- a/bridge/rector/FEATURE_PARITY.md +++ b/bridge/rector/FEATURE_PARITY.md @@ -17,7 +17,7 @@ Conversion coverage across the three directions supported by `testo/bridge-recto | **actual/expected argument swap** | βœ… | βœ… | βœ… | | **Fluent / typed chains** (`Assert::string()->…`, Pest `->not->`, `toBeGreaterThan`) | 🟑 *`TypedAssertChainRector` decomposes into separate `assert*` lines (incl. 1β†’N `hasKeys`, `between`, `isList`β†’`assertIsList`, `sameElementsAs`β†’`assertEqualsCanonicalizing`, the `iterable` headβ†’`assertIsIterable`, a bare head such as `Assert::callable($x)`β†’`assertIsCallable`, `allInstanceOf`β†’`assertContainsOnlyInstancesOf`, `allOf('int')`β†’`assertContainsOnlyInt` for the exact native types, `sameSizeAs`β†’`assertSameSize` on countable sides, `matchesRegex`/`notMatchesRegex`β†’`assertMatchesRegularExpression`/`assertDoesNotMatchRegularExpression`, `ignoringCase()->contains()`/`notContains()`β†’`assertStringContainsStringIgnoringCase`/`assertStringNotContainsStringIgnoringCase`, `ignoringLineEndings()->contains()`β†’`assertStringContainsStringIgnoringLineEndings`, `same`/`notSame`β†’`assertSame`/`assertNotSame`, `notStartsWith`/`notEndsWith`β†’`assertStringStartsNotWith`/`assertStringEndsNotWith`, `ignoringCase()->same()`/`notSame()`β†’`assertEqualsIgnoringCase`/`assertNotEqualsIgnoringCase`, `ignoringLineEndings()->same()`β†’`assertStringEqualsStringIgnoringLineEndings`, `ignoringWhitespace(true)->same()`/`notSame()`β†’`assertStringEqualsStringIgnoringWhitespace`/`NotEquals`); a non-variable subject is hoisted into a `$value` local so it is evaluated once; only inside a class (the emitted `$this->assert*` needs a method scope); JSON path/structure, `every`, the `callable` matchers (`isStatic`/`notStatic`, `hasReturnType`), custom matchers, and the string modifier combinations PHPUnit lacks (`ignoringCase()->startsWith()`, several modes together, `ignoringWhitespace()` without line breaks, `ignoringBlankLines()`, `ignoringAnsi()`) are left untouched + TODO* | 🟑 *`TypedAssertCallToTestoRector` converts the assertions whose faithful Testo form is a typed head + matcher: comparisons (`assertGreaterThan`β†’`Assert::numeric()->greaterThan()`, …), array keys (`assertArrayHasKey`β†’`Assert::array()->hasKeys()`), `assertEqualsCanonicalizing`β†’`sameElementsAs`, array-subject `assertEmpty`/`assertNotEmpty`β†’`blank`/`notBlank` (a non-object subject β†’ `Assert::true(empty($x))`), `assertStringStartsWith`/`EndsWith`β†’`Assert::string()->startsWith()`/`endsWith()`, `assertStringStartsNotWith`/`EndsNotWith`β†’`notStartsWith()`/`notEndsWith()`, `assertStringContainsString`/`NotContainsString`β†’`Assert::string()->contains()`/`notContains()`, `assertStringContainsStringIgnoringCase`/`NotContainsStringIgnoringCase`β†’`Assert::string()->ignoringCase()->contains()`/`notContains()`, `assertStringContainsStringIgnoringLineEndings`β†’`Assert::string()->ignoringLineEndings()->contains()`, `assertStringEqualsStringIgnoringLineEndings`β†’`->ignoringLineEndings()->same()`, `assertStringEqualsStringIgnoringWhitespace`/`NotEquals`β†’`->ignoringWhitespace(lineBreaks: true)->same()`/`notSame()`, `assertEqualsIgnoringCase`/`NotEqualsIgnoringCase`β†’`->ignoringCase()->same()`/`notSame()` (both sides strings), `assertMatchesRegularExpression`/`DoesNotMatchRegularExpression` (and the PHPUnit 9 `assertRegExp`/`assertNotRegExp`)β†’`Assert::string()->matchesRegex()`/`notMatchesRegex()`, `assertNotContains`β†’`Assert::iterable()->notContains()`, `assertObjectHasProperty`β†’`Assert::object()->hasProperty()`, `assertIsList`β†’`Assert::array()->isList()`, `assertContainsOnlyInt`/`String`/…→`Assert::iterable()->allOf('int')` and `assertContainsOnlyInstancesOf`β†’`allInstanceOf()`, `assertSameSize`β†’`Assert::iterable()->sameSizeAs()` (`Countable` sides), `assertJson`β†’`Assert::json()` (JSON equality stays: PHPUnit compares canonical re-encoded JSON), `assertIs`β†’the type head (`assertIsCallable`β†’`Assert::callable()` included; `assertIsBool`β†’the flat `Assert::bool()`), and the predicate checks (`assertIsScalar`, `assertIsNot*`, `assertFileExists`, `assertDirectoryExists`, …)β†’`Assert::true|false(\is_bool($x))`, also the file/directory permission checks (`\file_exists($f) && !\is_readable($f)`, side-effect-free path), `assertFinite`/`Infinite`/`Nan` (`int|float` subject), the resource checks over `\gettype()` (a closed resource counts), `assertNotInstanceOf`β†’`Assert::false($x instanceof Foo)` and `assertObjectNotHasProperty`β†’`Assert::false(\property_exists($o, $p))` (object subject). Each is a 1:1 statement rewrite; `MergeAssertChainRector` then folds adjacent same-head chains that open with the same comparison modifiers. General coalescing of arbitrary independent `assert*` lines remains impractical* | 🧩 *function host now exists; `ExpectToAssertRector` still leaves negated `->not->` and chained `->toX()->toY()` expectations untouched β€” mappable next* | | **Exception expectation (bare)** | βœ… *bare `\Testo\Expect::exception($c)` β†’ `$this->expectException($c)` (`ExpectExceptionToPhpUnitRector`); the attribute form `#[\Testo\Assert\ExpectException($c)]` β†’ prepended `$this->expectException($c)` (`ExpectExceptionAttributeToPhpUnitRector`)* | 🟑 | βœ… *`TestCallToFunctionRector` folds `->throws(X::class)` into a prepended `\Testo\Expect::exception(X)` + `never` return type* | -| **Exception message/code (fluent)** `withMessage/withCode` ↔ `expectExceptionMessage/Code` | βœ… *`ExpectExceptionToPhpUnitRector` expands one chain into several statements (`withMessageContaining`β†’`expectExceptionMessage`, `withCode`β†’`expectExceptionCode`, regex `withMessageMatchingRegex` (and the deprecated `withMessagePattern`)β†’`expectExceptionMessageMatches`); the exact `withMessage` also maps to `expectExceptionMessage` and loosens into a substring check; two modifiers on the same PHPUnit call abort the chain* | βœ… *`ExpectExceptionToTestoRector` folds the sibling `expectExceptionMessage/Code` after `expectException` into the `->withMessageContaining()/->withCode()` chain (StmtsAware), across assignments that cannot throw β€” PHPUnit matches the message as a substring; any other statement ends the run* | 🟑 *`->throws(X, 'msg')`'s second arg folds to `->withMessageContaining('msg')`; Pest has no exception-code modifier to map* | +| **Exception message/code (fluent)** `withMessage/withCode` ↔ `expectExceptionMessage/Code` | βœ… *`ExpectExceptionToPhpUnitRector` expands one chain into several statements (`withMessageContaining`β†’`expectExceptionMessage`, `withCode`β†’`expectExceptionCode`, regex `withMessageMatchingRegex` (and the deprecated `withMessagePattern`)β†’`expectExceptionMessageMatches`); the exact `withMessage` also maps to `expectExceptionMessage` and loosens into a substring check; two modifiers on the same PHPUnit call abort the chain* | βœ… *`ExpectExceptionToTestoRector` preserves exact-empty, substring, last-wins and named message/code expectations; dynamic messages use quoted replaceable regexes. Across assignments a local retains registration and argument snapshots. A dynamic/repeated message plus an independent regex remains manual* | 🟑 *`->throws(X, 'msg')`'s second arg folds to `->withMessageContaining('msg')`; Pest has no exception-code modifier to map* | | **Exception message by regex** (`expectExceptionMessageMatches`) | βœ… *`withMessageMatchingRegex`/`withMessagePattern`β†’`expectExceptionMessageMatches`* | βœ… *`ExpectExceptionToTestoRector` folds it into `->withMessageMatchingRegex()`* | βž– | | **Skip at runtime** (`throw SkipTest` ↔ `markTestSkipped`) | βœ… | βœ… *`MarkTestSkippedToTestoRector` takes what the attribute form below leaves: a guarded call, a call deeper in the body, a non-literal message* | βž– *every Pest `->skip()` is unconditional β€” see the row below* | | **Skip declaratively** (`#[Skip]`) | βœ… *`SkipAttributeToPhpUnitRector`: PHPUnit has no skip attribute, so `#[\Testo\Skip('reason')]` becomes a leading `$this->markTestSkipped('reason')` and the attribute is dropped; a class-level attribute is fanned out onto each test method (a method's own reason wins). **Residual:** the skip turns into a runtime one β€” PHPUnit runs `setUp()` and the data provider before aborting, where Testo keeps the test out of the pipeline entirely* | βœ… *`MarkTestSkippedToSkipAttributeRector`: an unconditional `markTestSkipped('literal')` opening a test method β†’ `#[\Testo\Skip('literal')]`, dropping the call. A guarded/non-literal one stays a throw (row above); a call opening `setUp()` stays a throw too, rather than becoming a class-level `#[Skip]`* | βœ… *`->skip()` / `->skip('reason')` β†’ `#[\Testo\Skip('reason')]`; a conditional `->skip(fn () => …)` / `->skip($bool, 'reason')` leaves the whole statement untouched* | diff --git a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector.php b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector.php index 9db739cd..7a31e41d 100644 --- a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector.php +++ b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector.php @@ -8,18 +8,25 @@ use PhpParser\Node\Arg; use PhpParser\Node\Expr; use PhpParser\Node\Expr\Array_; +use PhpParser\Node\Expr\ArrowFunction; use PhpParser\Node\Expr\Assign; use PhpParser\Node\Expr\BinaryOp\Concat; +use PhpParser\Node\Expr\BinaryOp\Identical; use PhpParser\Node\Expr\ClassConstFetch; use PhpParser\Node\Expr\ConstFetch; +use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\StaticCall; +use PhpParser\Node\Expr\Ternary; use PhpParser\Node\Expr\Variable; use PhpParser\Node\Identifier; use PhpParser\Node\Name; use PhpParser\Node\Name\FullyQualified; +use PhpParser\Node\Param; use PhpParser\Node\Scalar; +use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt\Expression; +use PhpParser\NodeFinder; use PHPStan\Analyser\Scope; use Rector\Contract\PhpParser\Node\StmtsAwareInterface; use Rector\NodeTypeResolver\Node\AttributeKey; @@ -36,8 +43,9 @@ * The head `$this->expectException($c)` (also `self::`/`static::`) becomes * `\Testo\Expect::exception($c)`, and each immediately-following sibling statement is absorbed as a * chained modifier and removed: - * - `expectExceptionMessage($m)` => `->withMessageContaining($m)` β€” PHPUnit matches a substring, - * so the exact `->withMessage()` would fail tests that passed before; + * - a single nonempty literal message => `->withMessageContaining($m)`; + * - an empty literal message => `->withMessage('')`; + * - repeated or dynamic messages => a quoted, replaceable `->withMessageMatchingRegex()`; * - `expectExceptionMessageMatches($re)` => `->withMessageMatchingRegex($re)`; * - `expectExceptionCode($n)` => `->withCode($n)`. * @@ -51,12 +59,15 @@ * statements), it operates at the statements level: it matches the enclosing * {@see StmtsAwareInterface} node and rewrites its `->stmts`. * - * Conservative by design: only folding calls that are siblings after the `expectException` statement - * are absorbed, and the only statements allowed between them are assignments that cannot throw - * (`$message = 'Type "float" is not supported.';`). The chain then takes the place of the last - * absorbed call, after those assignments. Any other statement ends the run: code that may throw - * decides which expectations PHPUnit had registered by then. A bare - * `expectExceptionMessage`/`Code` with no preceding `expectException` is left untouched. + * Arguments keep their evaluation order, including overwritten expectations. A typed closure + * converts a dynamic message to a literal substring pattern (exact-empty for an empty value). + * Named source arguments are validated and emitted positionally. Across assignments, a fresh local + * holds the handler so registration and argument snapshots stay at their original statements. + * Any other statement ends the run. A bare message/code with no preceding class is left untouched. + * + * PHPUnit has independent message and regex fields. A single literal message can coexist with + * a regex in Testo; a dynamic/repeated message plus an independent regex cannot share Testo's one + * replaceable regex field and the whole run is left for manual migration. * * Only folds inside a class: the expectations belong to a test method, so a run in a free function * or at namespace level is left untouched. @@ -122,23 +133,37 @@ public function refactor(Node $node): ?Node continue; } + $argument = $this->argument($head, 'exception'); + if ($argument === null) { + $result[] = $stmt; + continue; + } + $chain = new StaticCall( new FullyQualified('Testo\\Expect'), new Identifier('exception'), - $head->args, + [new Arg($argument)], ); - # Absorb the expectExceptionMessage/Code siblings that follow, across assignments that - # cannot throw. The chain lands where the last modifier stood, after those assignments, - # so a modifier argument they define (`$message = '…';`) is set by then. - $between = []; + # Collect sibling modifiers, retaining intervening assignments in their source order. + $modifiers = []; $pending = []; + $hasAssignments = false; + $hasMessage = false; + $hasPattern = false; + $messageCount = 0; + $literalMessage = false; for ($j = $i + 1; $j < $count; ++$j) { $next = $stmts[$j]; $modifier = $next instanceof Expression ? $this->matchModifier($next) : null; if ($modifier !== null) { - $chain = new MethodCall($chain, new Identifier($modifier[0]), $modifier[1]); - \array_push($between, ...$pending); + $hasAssignments = $hasAssignments || $pending !== []; + \array_push($modifiers, ...$pending); + $modifiers[] = [$next, $modifier]; + $hasMessage = $hasMessage || $modifier[0] === 'message'; + $hasPattern = $hasPattern || $modifier[0] === 'withMessageMatchingRegex'; + $messageCount += (int) ($modifier[0] === 'message'); + $literalMessage = $literalMessage || ($modifier[0] === 'message' && $modifier[1] instanceof String_); $pending = []; $i = $j; continue; @@ -152,8 +177,46 @@ public function refactor(Node $node): ?Node } \assert($stmt instanceof Expression); + if ($hasMessage && $hasPattern && ($messageCount !== 1 || !$literalMessage)) { + # There is only one replaceable regex slot in Testo. Do not conflate PHPUnit's + # independent fields, or register a second handler that could consume a failure + # from the first when the expected class is Throwable/Exception. + $result[] = $stmt; + foreach ($modifiers as $modifier) { + $result[] = \is_array($modifier) ? $modifier[0] : $modifier; + } + continue; + } + if ($hasAssignments) { + # Keep registration and every argument evaluation at its original statement. + $expectation = $this->temporaryName($node); + if ($expectation === null) { + $result[] = $stmt; + foreach ($modifiers as $modifier) { + $result[] = \is_array($modifier) ? $modifier[0] : $modifier; + } + continue; + } + + $stmt->expr = new Assign($expectation, $chain); + $result[] = $stmt; + foreach ($modifiers as $modifier) { + if (!\is_array($modifier)) { + $result[] = $modifier; + continue; + } + [$statement, [$method, $value]] = $modifier; + $statement->expr = $this->modifier($expectation, $method, $value, $messageCount); + $result[] = $statement; + } + $changed = true; + continue; + } + + foreach ($modifiers as [, [$method, $value]]) { + $chain = $this->modifier($chain, $method, $value, $messageCount); + } $stmt->expr = $chain; - \array_push($result, ...$between); $result[] = $stmt; $changed = true; } @@ -194,7 +257,7 @@ private function matchExpectException(Expression $stmt): MethodCall|StaticCall|n * Maps a foldable `$this->expectExceptionMessage/MessageMatches/Code(...)` statement to a `[method, args]` * pair for the fluent chain, or null when the statement is not a foldable modifier. * - * @return array{0: non-empty-string, 1: array}|null + * @return array{non-empty-string, Expr}|null */ private function matchModifier(Expression $stmt): ?array { @@ -212,12 +275,80 @@ private function matchModifier(Expression $stmt): ?array return null; } - return match (true) { - $this->isName($expr->name, 'expectExceptionMessage') => ['withMessageContaining', $expr->args], - $this->isName($expr->name, 'expectExceptionMessageMatches') => ['withMessageMatchingRegex', $expr->args], - $this->isName($expr->name, 'expectExceptionCode') => ['withCode', $expr->args], + $mapping = match (true) { + $this->isName($expr->name, 'expectExceptionMessage') => ['message', 'message'], + $this->isName($expr->name, 'expectExceptionMessageMatches') => ['withMessageMatchingRegex', 'regularExpression'], + $this->isName($expr->name, 'expectExceptionCode') => ['withCode', 'code'], default => null, }; + if ($mapping === null) { + return null; + } + $argument = $this->argument($expr, $mapping[1]); + return $argument === null ? null : [$mapping[0], $argument]; + } + + private function argument(MethodCall|StaticCall $call, string $parameter): ?Expr + { + if (\count($call->args) !== 1 || !$call->args[0] instanceof Arg) { + return null; + } + $argument = $call->args[0]; + return $argument->unpack || ($argument->name !== null && $argument->name->toString() !== $parameter) + ? null : $argument->value; + } + + private function modifier(Expr $receiver, string $method, Expr $value, int $messageCount): MethodCall + { + if ($method === 'message') { + if ($messageCount === 1 && $value instanceof String_) { + $method = $value->value === '' ? 'withMessage' : 'withMessageContaining'; + } else { + $method = 'withMessageMatchingRegex'; + $value = $this->messagePattern($value); + } + } + return new MethodCall($receiver, new Identifier($method), [new Arg($value)]); + } + + private function messagePattern(Expr $message): Expr + { + if ($message instanceof String_) { + return new String_($message->value === '' ? '~\\A\\z~' : '~' . \preg_quote($message->value, '~') . '~'); + } + + # A typed closure keeps PHPUnit's string parameter boundary (weak/strict caller coercion) + # and evaluates the original expression only once, including an overwritten expectation. + $value = new Variable('message'); + return new FuncCall(new ArrowFunction([ + 'static' => true, + 'params' => [new Param($value, type: new Identifier('string'))], + 'returnType' => new Identifier('string'), + 'expr' => new Ternary( + new Identical($value, new String_('')), + new String_('~\\A\\z~'), + new Concat(new Concat(new String_('~'), new FuncCall(new FullyQualified('preg_quote'), [ + new Arg($value), new Arg(new String_('~')), + ])), new String_('~')), + ), + ]), [new Arg($message)]); + } + + private function temporaryName(Node $node): ?Variable + { + $used = []; + foreach ((new NodeFinder())->findInstanceOf($node, Variable::class) as $variable) { + if (!\is_string($variable->name)) { + return null; + } + $used[$variable->name] = true; + } + $scope = $node->getAttribute(AttributeKey::SCOPE); + $name = 'testoExpectedException'; + for ($suffix = 2; isset($used[$name]) || ($scope instanceof Scope && $scope->hasVariableType($name)->yes()); ++$suffix) { + $name = 'testoExpectedException' . $suffix; + } + return new Variable($name); } /** diff --git a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/fold_across_assignment.php.inc b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/fold_across_assignment.php.inc index 09cfd6b1..6053219c 100644 --- a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/fold_across_assignment.php.inc +++ b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/fold_across_assignment.php.inc @@ -18,8 +18,9 @@ class FoldAcrossAssignmentTest { public function testInitWithWrongType(): void { + $testoExpectedException = \Testo\Expect::exception(\InvalidArgumentException::class); $message = 'Type "float" is not supported. The valid types are: ' . self::TYPES . '.'; - \Testo\Expect::exception(\InvalidArgumentException::class)->withMessageContaining($message); + $testoExpectedException->withMessageMatchingRegex((static fn(string $message): string => $message === '' ? '~\A\z~' : '~' . \preg_quote($message, '~') . '~')($message)); new Compare(type: 'float'); } diff --git a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/independent_dynamic_constraints_unchanged.php.inc b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/independent_dynamic_constraints_unchanged.php.inc new file mode 100644 index 00000000..ce843862 --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/independent_dynamic_constraints_unchanged.php.inc @@ -0,0 +1,19 @@ +expectException(\Throwable::class); + $this->expectExceptionMessage('old'); + $this->expectExceptionMessageMatches('~two~'); + $this->expectExceptionMessage($this->message()); + } + + public function dynamicMessageAndPattern(): void + { + $this->expectException(\Throwable::class); + $this->expectExceptionMessage($this->message()); + $this->expectExceptionMessageMatches('~two~'); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/runtime_contracts.php.inc b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/runtime_contracts.php.inc new file mode 100644 index 00000000..c245e943 --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/runtime_contracts.php.inc @@ -0,0 +1,222 @@ +expectException(\RuntimeException::class); + $this->expectExceptionMessage(''); + throw new \RuntimeException('nonempty'); + } + + public function emptyPasses(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage(''); + throw new \RuntimeException(''); + } + + public function lastMessageWins(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('one'); + $this->expectExceptionMessage('two'); + throw new \RuntimeException('two'); + } + + public function namedArguments(): void + { + $this->expectException(exception: \RuntimeException::class); + $this->expectExceptionMessage(message: 'named'); + $this->expectExceptionCode(code: 7); + throw new \RuntimeException('named', 7); + } + + public function literalRegexCharacters(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('old'); + $this->expectExceptionMessage('~.[x]+$\\'); + throw new \RuntimeException('prefix ~.[x]+$\\ suffix'); + } + + public function dynamicEmptyMustFail(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage($this->mark('')); + throw new \RuntimeException('nonempty'); + } + + public function evaluationOrder(): void + { + $this->trace = []; + $this->expectException($this->mark(\RuntimeException::class)); + $this->expectExceptionMessage($this->mark('one')); + $this->expectExceptionMessage($this->mark('two')); + $this->expectExceptionCode(1); + $this->expectExceptionCode(2); + \Testo\Assert::same($this->trace, [\RuntimeException::class, 'one', 'two']); + throw new \RuntimeException('two', 2); + } + + public function overwrittenArgumentThrows(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('one'); + $this->expectExceptionMessage($this->throwOne()); + } + + public function independentPattern(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('two'); + $this->expectExceptionMessageMatches(regularExpression: '~^two$~'); + throw new \RuntimeException('two'); + } + + public function independentPatternMustFail(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('two'); + $this->expectExceptionMessageMatches('~^three$~'); + throw new \RuntimeException('two'); + } + + public function assignmentKeepsSnapshots(): void + { + $class = \RuntimeException::class; + $message = 'one'; + $this->expectException($class); + $this->expectExceptionMessage($message); + $class = \LogicException::class; + $message = 'two'; + $this->expectExceptionMessage($message); + throw new \RuntimeException('two'); + } + + public function temporaryDoesNotClobberSource(): void + { + $testoExpectedException = 'source'; + $this->expectException(\RuntimeException::class); + $message = 'two'; + $this->expectExceptionMessage($message); + \Testo\Assert::same($testoExpectedException, 'source'); + throw new \RuntimeException('two'); + } + + private function mark(string $value): string + { + $this->trace[] = $value; + return $value; + } + + private function throwOne(): string + { + throw new \RuntimeException('one'); + } +} +----- +withMessage(''); + throw new \RuntimeException('nonempty'); + } + + public function emptyPasses(): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessage(''); + throw new \RuntimeException(''); + } + + public function lastMessageWins(): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessageMatchingRegex('~one~')->withMessageMatchingRegex('~two~'); + throw new \RuntimeException('two'); + } + + public function namedArguments(): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessageContaining('named')->withCode(7); + throw new \RuntimeException('named', 7); + } + + public function literalRegexCharacters(): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessageMatchingRegex('~old~')->withMessageMatchingRegex('~\~\.\[x\]\+\$\\\\~'); + throw new \RuntimeException('prefix ~.[x]+$\\ suffix'); + } + + public function dynamicEmptyMustFail(): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessageMatchingRegex((static fn(string $message): string => $message === '' ? '~\A\z~' : '~' . \preg_quote($message, '~') . '~')($this->mark(''))); + throw new \RuntimeException('nonempty'); + } + + public function evaluationOrder(): void + { + $this->trace = []; + \Testo\Expect::exception($this->mark(\RuntimeException::class))->withMessageMatchingRegex((static fn(string $message): string => $message === '' ? '~\A\z~' : '~' . \preg_quote($message, '~') . '~')($this->mark('one')))->withMessageMatchingRegex((static fn(string $message): string => $message === '' ? '~\A\z~' : '~' . \preg_quote($message, '~') . '~')($this->mark('two')))->withCode(1)->withCode(2); + \Testo\Assert::same($this->trace, [\RuntimeException::class, 'one', 'two']); + throw new \RuntimeException('two', 2); + } + + public function overwrittenArgumentThrows(): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessageMatchingRegex('~one~')->withMessageMatchingRegex((static fn(string $message): string => $message === '' ? '~\A\z~' : '~' . \preg_quote($message, '~') . '~')($this->throwOne())); + } + + public function independentPattern(): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessageContaining('two')->withMessageMatchingRegex('~^two$~'); + throw new \RuntimeException('two'); + } + + public function independentPatternMustFail(): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessageContaining('two')->withMessageMatchingRegex('~^three$~'); + throw new \RuntimeException('two'); + } + + public function assignmentKeepsSnapshots(): void + { + $class = \RuntimeException::class; + $message = 'one'; + $testoExpectedException = \Testo\Expect::exception($class); + $testoExpectedException->withMessageMatchingRegex((static fn(string $message): string => $message === '' ? '~\A\z~' : '~' . \preg_quote($message, '~') . '~')($message)); + $class = \LogicException::class; + $message = 'two'; + $testoExpectedException->withMessageMatchingRegex((static fn(string $message): string => $message === '' ? '~\A\z~' : '~' . \preg_quote($message, '~') . '~')($message)); + throw new \RuntimeException('two'); + } + + public function temporaryDoesNotClobberSource(): void + { + $testoExpectedException = 'source'; + $testoExpectedException2 = \Testo\Expect::exception(\RuntimeException::class); + $message = 'two'; + $testoExpectedException2->withMessageMatchingRegex((static fn(string $message): string => $message === '' ? '~\A\z~' : '~' . \preg_quote($message, '~') . '~')($message)); + \Testo\Assert::same($testoExpectedException, 'source'); + throw new \RuntimeException('two'); + } + + private function mark(string $value): string + { + $this->trace[] = $value; + return $value; + } + + private function throwOne(): string + { + throw new \RuntimeException('one'); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/unsupported_arguments_unchanged.php.inc b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/unsupported_arguments_unchanged.php.inc new file mode 100644 index 00000000..2a144859 --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/unsupported_arguments_unchanged.php.inc @@ -0,0 +1,12 @@ +expectException(wrong: \RuntimeException::class); + $this->expectException(...$arguments); + $this->expectException(\RuntimeException::class, 'extra'); + $this->expectException(...); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/TODO.md b/bridge/rector/src/PhpunitToTesto/TODO.md index 5fb153de..cda125ec 100644 --- a/bridge/rector/src/PhpunitToTesto/TODO.md +++ b/bridge/rector/src/PhpunitToTesto/TODO.md @@ -43,17 +43,16 @@ Mocks are out of this set: they convert through `phpunit-to-double` or `phpunit- `#[\Override]` and only gains the `#[\Testo\Test]` marks, as do the non-abstract test methods of a trait. **Residual:** methods are NOT renamed β€” Testo discovers by attribute, so keeping `testFoo()` is harmless, and prefix cleanup / call-site rewriting is left manual. -- **ExpectExceptionToTestoRector** (registered) β€” now folds the fluent chain, not just the bare - head. It operates at the statements level (matches the enclosing `StmtsAwareInterface` node and - rewrites its `->stmts`): after a `$this->expectException($c)` statement it absorbs the - following sibling `expectExceptionMessage($m)` / - `expectExceptionMessageMatches($re)` / `expectExceptionCode($n)` statements into - `\Testo\Expect::exception($c)->withMessageContaining($m)->withMessageMatchingRegex($re)->withCode($n)` - and removes them. PHPUnit's `expectExceptionMessage()` matches a substring, hence - `withMessageContaining()` rather than the exact `withMessage()`. Conservative: only assignments that - cannot throw (`$message = '…';`) may stand between the calls, and the chain takes the place of the - last absorbed one after them; any other statement ends the run, - and a bare `expectExceptionMessage`/`Code` with no preceding `expectException` is left untouched. +- **ExpectExceptionToTestoRector** (registered) β€” folds sibling class/message/regex/code + expectations while preserving argument evaluation order and last-wins messages/codes. A single + nonempty literal uses `withMessageContaining()`, an empty literal uses exact `withMessage('')`, + and dynamic/repeated messages use a quoted, replaceable regex (exact-empty for `''`). Named source + parameters are validated and emitted positionally. Across assignments a fresh local holds the + handler, keeping registration and argument snapshots at the original statements. Other statements + end the run. A single literal message and an independent regex both convert; a dynamic/repeated + message plus an independent regex stays **entirely unchanged for manual migration**, since Testo + has only one replaceable regex slot. Variable variables also prevent introducing a handler local. + A bare message/code with no preceding class expectation remains manual. - **GroupToTestoRector** (registered) β€” collapses every PHPUnit group source on a node β€” the `@group` docblock annotation(s) **and** the repeatable single-name `#[Group]` attribute(s) β€” into one variadic `#[\Testo\Filter\Group('a', 'b', …)]` (Testo's `Group` is variadic but not 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/Unit/ExceptionExpectationRuntimeTest.php b/bridge/rector/tests/Unit/ExceptionExpectationRuntimeTest.php new file mode 100644 index 00000000..49118b8f --- /dev/null +++ b/bridge/rector/tests/Unit/ExceptionExpectationRuntimeTest.php @@ -0,0 +1,43 @@ +convert($input); + $result = ConvertedRuntime::run($converted); + + $totals = $result['report']['totals']; + Assert::same([$totals['total'], $totals['assertions'], $totals['passed'], $totals['failed']], [12, 14, 9, 3]); + Assert::same(\array_column($result['report']['failures'], 'test'), [ + 'ExceptionRuntimeContracts::emptyMustFail', + 'ExceptionRuntimeContracts::dynamicEmptyMustFail', + 'ExceptionRuntimeContracts::independentPatternMustFail', + ]); + $junit = new \SimpleXMLElement($result['junit']); + $assertions = []; + foreach ($junit->xpath('//testcase') as $case) { + $assertions[(string) $case['name']] = (int) $case['assertions']; + } + Assert::same($assertions['evaluationOrder'], 2); + Assert::same($assertions['independentPattern'], 1); + Assert::same($assertions['emptyMustFail'], 1); + } +} diff --git a/bridge/rector/tests/Unit/Fixture/ConvertedRuntime.php b/bridge/rector/tests/Unit/Fixture/ConvertedRuntime.php new file mode 100644 index 00000000..f9aa64b9 --- /dev/null +++ b/bridge/rector/tests/Unit/Fixture/ConvertedRuntime.php @@ -0,0 +1,54 @@ + ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', '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]); + $stderr = (string) \stream_get_contents($pipes[2]); + \fclose($pipes[1]); + \fclose($pipes[2]); + $exit = \proc_close($process); + $report = \json_decode($stdout, true); + if (!\is_array($report) || !isset($report['status']) || !\is_file($junit)) { + throw new \RuntimeException("Converted suite exited $exit without a report: $stdout\n$stderr"); + } + return ['report' => $report, 'junit' => (string) \file_get_contents($junit)]; + } finally { + foreach ([$input, $config, $junit] as $file) { + \is_file($file) and \unlink($file); + } + \rmdir($directory); + } + } +} 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..da132958 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 @@ -62,7 +62,7 @@ the assertion **argument order flips** (see the pitfalls), and discovery is attr | `$this->assertIsResource($x)` / `assertIsClosedResource($x)` | `Assert::true(\str_starts_with(\gettype($x), 'resource'))` / `Assert::same(\gettype($x), 'resource (closed)')` β€” PHPUnit counts a closed resource, `is_resource()` does not. | | `$this->assertJson($s)` | `Assert::json($s)` (no `$message` arg). The JSON equality assertions have no counterpart: PHPUnit compares canonical re-encoded JSON (`1` β‰  `1.0`, `{}` β‰  `[]`), which `Assert::equals()` over decoded values does not. | | `$this->expectException(X::class)` before Act | `Expect::exception(X::class)->withCode(...)` before Act. Method return type becomes `never`. | -| `$this->expectExceptionMessage('...')` | `->withMessageContaining('...')` β€” PHPUnit matches a substring; `withMessage()` is an exact match and fails where PHPUnit passed. | +| `$this->expectExceptionMessage('...')` | A single nonempty literal uses `->withMessageContaining('...')`; `''` uses exact `->withMessage('')`. Dynamic or repeated messages use a quoted `withMessageMatchingRegex()` so the last message wins and earlier argument evaluations remain. A dynamic/repeated message combined with an independent `expectExceptionMessageMatches()` stays manual. | | `$this->expectExceptionMessageMatches('/.../')` | `->withMessageMatchingRegex('/.../')`. | | `$this->markTestSkipped('reason')` | `#[Skip('reason')]` from **`Testo\Skip`** when the call opens the test unconditionally β€” the test then never enters the pipeline (no hooks, no provider, no retries). A guarded call, one deeper in the body, or a non-literal message stays runtime: `throw new \Testo\Core\Exception\SkipTest('reason')` from the test body. | | `$this->markTestIncomplete('reason')` | No "incomplete" status. Port to `throw new SkipTest('TODO: reason')`, or leave the body empty β†’ `Status::Risky`. | From 6eaad6d1c3760463873d6d86001e76bd8ecff41c Mon Sep 17 00:00:00 2001 From: Ichi Date: Thu, 15 Oct 2026 15:49:28 +0500 Subject: [PATCH 2/4] refactor(rector): simplify exception expectation regression checks Run converted fixtures through a nested application instead of a subprocess and JSON/JUnit reports. Remove the redundant message flag and restore unsupported runs from the original statement range. Refs #389 Assisted-By: GPT-6 --- .../ExpectExceptionToTestoRector.php | 15 ++---- .../Unit/ExceptionExpectationRuntimeTest.php | 44 +++++++++++---- .../tests/Unit/Fixture/ConvertedRuntime.php | 54 ------------------- 3 files changed, 38 insertions(+), 75 deletions(-) delete mode 100644 bridge/rector/tests/Unit/Fixture/ConvertedRuntime.php diff --git a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector.php b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector.php index 7a31e41d..9514524a 100644 --- a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector.php +++ b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector.php @@ -146,10 +146,10 @@ public function refactor(Node $node): ?Node ); # Collect sibling modifiers, retaining intervening assignments in their source order. + $start = $i; $modifiers = []; $pending = []; $hasAssignments = false; - $hasMessage = false; $hasPattern = false; $messageCount = 0; $literalMessage = false; @@ -160,7 +160,6 @@ public function refactor(Node $node): ?Node $hasAssignments = $hasAssignments || $pending !== []; \array_push($modifiers, ...$pending); $modifiers[] = [$next, $modifier]; - $hasMessage = $hasMessage || $modifier[0] === 'message'; $hasPattern = $hasPattern || $modifier[0] === 'withMessageMatchingRegex'; $messageCount += (int) ($modifier[0] === 'message'); $literalMessage = $literalMessage || ($modifier[0] === 'message' && $modifier[1] instanceof String_); @@ -177,24 +176,18 @@ public function refactor(Node $node): ?Node } \assert($stmt instanceof Expression); - if ($hasMessage && $hasPattern && ($messageCount !== 1 || !$literalMessage)) { + if ($messageCount > 0 && $hasPattern && ($messageCount !== 1 || !$literalMessage)) { # There is only one replaceable regex slot in Testo. Do not conflate PHPUnit's # independent fields, or register a second handler that could consume a failure # from the first when the expected class is Throwable/Exception. - $result[] = $stmt; - foreach ($modifiers as $modifier) { - $result[] = \is_array($modifier) ? $modifier[0] : $modifier; - } + \array_push($result, ...\array_slice($stmts, $start, $i - $start + 1)); continue; } if ($hasAssignments) { # Keep registration and every argument evaluation at its original statement. $expectation = $this->temporaryName($node); if ($expectation === null) { - $result[] = $stmt; - foreach ($modifiers as $modifier) { - $result[] = \is_array($modifier) ? $modifier[0] : $modifier; - } + \array_push($result, ...\array_slice($stmts, $start, $i - $start + 1)); continue; } diff --git a/bridge/rector/tests/Unit/ExceptionExpectationRuntimeTest.php b/bridge/rector/tests/Unit/ExceptionExpectationRuntimeTest.php index 49118b8f..f437c722 100644 --- a/bridge/rector/tests/Unit/ExceptionExpectationRuntimeTest.php +++ b/bridge/rector/tests/Unit/ExceptionExpectationRuntimeTest.php @@ -4,12 +4,17 @@ namespace Tests\Bridge\Rector\Unit; +use Testo\Application\Application; +use Testo\Application\Config\ApplicationConfig; +use Testo\Application\Config\FinderConfig; +use Testo\Application\Config\SuiteConfig; use Testo\Assert; use Testo\Bridge\Rector\PhpunitToTesto\ExpectExceptionToTestoRector; use Testo\Bridge\Rector\Testing\Internal\RectorRunner; use Testo\Codecov\CoversNothing; +use Testo\Core\Value\Status; +use Testo\Output\ConsoleStreams; use Testo\Test; -use Tests\Bridge\Rector\Unit\Fixture\ConvertedRuntime; use Tests\Bridge\Rector\Unit\Fixture\RecordingMessenger; #[Test] @@ -22,20 +27,39 @@ public function convertedExpectationsKeepTheirVerdictsAndEvaluationOrder(): void [$input] = \explode("-----\n", \str_replace("\r\n", "\n", (string) \file_get_contents($fixture)), 2); $runner = new RectorRunner(new RecordingMessenger(), [ExpectExceptionToTestoRector::class]); $converted = $runner->convert($input); - $result = ConvertedRuntime::run($converted); + $file = \sys_get_temp_dir() . '/testo-converted-' . \bin2hex(\random_bytes(8)) . '.php'; + $sink = \fopen('php://memory', 'w+b'); + try { + \file_put_contents($file, $converted); + $app = Application::createFromConfig(new ApplicationConfig( + suites: [new SuiteConfig('Converted', location: new FinderConfig(include: [$file]))], + )); + $app->getContainer()->set(new ConsoleStreams($sink, $sink), ConsoleStreams::class); + $result = $app->run(); + } finally { + \fclose($sink); + @\unlink($file); + } - $totals = $result['report']['totals']; - Assert::same([$totals['total'], $totals['assertions'], $totals['passed'], $totals['failed']], [12, 14, 9, 3]); - Assert::same(\array_column($result['report']['failures'], 'test'), [ + $totals = $result->summary; + Assert::same([$totals->total(), $totals->metric('assertions'), $totals->count(Status::Passed), $totals->count(Status::Failed)], [12, 14, 9, 3]); + $failures = []; + $assertions = []; + foreach ($result as $suite) { + foreach ($suite as $case) { + foreach ($case as $test) { + if ($test->status->isFailure()) { + $failures[] = $test->info->identity->fqn(); + } + $assertions[$test->info->name] = $test->summary->metric('assertions'); + } + } + } + Assert::same($failures, [ 'ExceptionRuntimeContracts::emptyMustFail', 'ExceptionRuntimeContracts::dynamicEmptyMustFail', 'ExceptionRuntimeContracts::independentPatternMustFail', ]); - $junit = new \SimpleXMLElement($result['junit']); - $assertions = []; - foreach ($junit->xpath('//testcase') as $case) { - $assertions[(string) $case['name']] = (int) $case['assertions']; - } Assert::same($assertions['evaluationOrder'], 2); Assert::same($assertions['independentPattern'], 1); Assert::same($assertions['emptyMustFail'], 1); diff --git a/bridge/rector/tests/Unit/Fixture/ConvertedRuntime.php b/bridge/rector/tests/Unit/Fixture/ConvertedRuntime.php deleted file mode 100644 index f9aa64b9..00000000 --- a/bridge/rector/tests/Unit/Fixture/ConvertedRuntime.php +++ /dev/null @@ -1,54 +0,0 @@ - ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', '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]); - $stderr = (string) \stream_get_contents($pipes[2]); - \fclose($pipes[1]); - \fclose($pipes[2]); - $exit = \proc_close($process); - $report = \json_decode($stdout, true); - if (!\is_array($report) || !isset($report['status']) || !\is_file($junit)) { - throw new \RuntimeException("Converted suite exited $exit without a report: $stdout\n$stderr"); - } - return ['report' => $report, 'junit' => (string) \file_get_contents($junit)]; - } finally { - foreach ([$input, $config, $junit] as $file) { - \is_file($file) and \unlink($file); - } - \rmdir($directory); - } - } -} From 2c19c3ab6b9a26455ea1d3bdd9047ce9c53700b8 Mon Sep 17 00:00:00 2001 From: Ichi Date: Thu, 15 Oct 2026 16:28:26 +0500 Subject: [PATCH 3/4] fix(rector): preserve long exception message expectations Add an optional exact comparison mode to withMessage so migrations can replace substring expectations without PCRE length limits. Retain exact matching by default and keep regex constraints independent. Cover long messages and mode changes through transformed runtime controls, native PHPUnit parity, and the Assert suites. Document the API and handle literal modes in the reverse conversion. Refs #389 Assisted-By: GPT-6 --- bridge/rector/FEATURE_PARITY.md | 2 +- .../ExpectExceptionToTestoRector.php | 53 +++----- .../fold_across_assignment.php.inc | 2 +- .../independent_dynamic_constraints.php.inc | 34 +++++ ...dent_dynamic_constraints_unchanged.php.inc | 19 --- .../runtime_contracts.php.inc | 121 ++++++++++++++++-- bridge/rector/src/PhpunitToTesto/TODO.md | 8 +- .../ExpectExceptionToPhpUnitRector.php | 27 +++- .../dynamic_message_mode_unchanged.php.inc | 11 ++ .../with_message_mode.php.inc | 31 +++++ bridge/rector/src/TestoToPhpunit/TODO.md | 3 + .../Unit/ExceptionExpectationRuntimeTest.php | 4 +- plugin/assert/src/Api/ExpectedException.php | 7 +- .../Expectation/ExpectExceptionHandler.php | 16 ++- .../Feature/ExpectExceptionNegativeTest.php | 18 +++ .../assert/tests/Self/ExpectExceptionTest.php | 37 ++++++ .../tests/Stub/ExpectExceptionNegative.php | 18 +++ .../references/phpunit-to-testo-map.md | 2 +- skills/testo-write-tests/SKILL.md | 3 + 19 files changed, 336 insertions(+), 80 deletions(-) create mode 100644 bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/independent_dynamic_constraints.php.inc delete mode 100644 bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/independent_dynamic_constraints_unchanged.php.inc create mode 100644 bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector/dynamic_message_mode_unchanged.php.inc create mode 100644 bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector/with_message_mode.php.inc diff --git a/bridge/rector/FEATURE_PARITY.md b/bridge/rector/FEATURE_PARITY.md index abe86c11..d5772798 100644 --- a/bridge/rector/FEATURE_PARITY.md +++ b/bridge/rector/FEATURE_PARITY.md @@ -17,7 +17,7 @@ Conversion coverage across the three directions supported by `testo/bridge-recto | **actual/expected argument swap** | βœ… | βœ… | βœ… | | **Fluent / typed chains** (`Assert::string()->…`, Pest `->not->`, `toBeGreaterThan`) | 🟑 *`TypedAssertChainRector` decomposes into separate `assert*` lines (incl. 1β†’N `hasKeys`, `between`, `isList`β†’`assertIsList`, `sameElementsAs`β†’`assertEqualsCanonicalizing`, the `iterable` headβ†’`assertIsIterable`, a bare head such as `Assert::callable($x)`β†’`assertIsCallable`, `allInstanceOf`β†’`assertContainsOnlyInstancesOf`, `allOf('int')`β†’`assertContainsOnlyInt` for the exact native types, `sameSizeAs`β†’`assertSameSize` on countable sides, `matchesRegex`/`notMatchesRegex`β†’`assertMatchesRegularExpression`/`assertDoesNotMatchRegularExpression`, `ignoringCase()->contains()`/`notContains()`β†’`assertStringContainsStringIgnoringCase`/`assertStringNotContainsStringIgnoringCase`, `ignoringLineEndings()->contains()`β†’`assertStringContainsStringIgnoringLineEndings`, `same`/`notSame`β†’`assertSame`/`assertNotSame`, `notStartsWith`/`notEndsWith`β†’`assertStringStartsNotWith`/`assertStringEndsNotWith`, `ignoringCase()->same()`/`notSame()`β†’`assertEqualsIgnoringCase`/`assertNotEqualsIgnoringCase`, `ignoringLineEndings()->same()`β†’`assertStringEqualsStringIgnoringLineEndings`, `ignoringWhitespace(true)->same()`/`notSame()`β†’`assertStringEqualsStringIgnoringWhitespace`/`NotEquals`); a non-variable subject is hoisted into a `$value` local so it is evaluated once; only inside a class (the emitted `$this->assert*` needs a method scope); JSON path/structure, `every`, the `callable` matchers (`isStatic`/`notStatic`, `hasReturnType`), custom matchers, and the string modifier combinations PHPUnit lacks (`ignoringCase()->startsWith()`, several modes together, `ignoringWhitespace()` without line breaks, `ignoringBlankLines()`, `ignoringAnsi()`) are left untouched + TODO* | 🟑 *`TypedAssertCallToTestoRector` converts the assertions whose faithful Testo form is a typed head + matcher: comparisons (`assertGreaterThan`β†’`Assert::numeric()->greaterThan()`, …), array keys (`assertArrayHasKey`β†’`Assert::array()->hasKeys()`), `assertEqualsCanonicalizing`β†’`sameElementsAs`, array-subject `assertEmpty`/`assertNotEmpty`β†’`blank`/`notBlank` (a non-object subject β†’ `Assert::true(empty($x))`), `assertStringStartsWith`/`EndsWith`β†’`Assert::string()->startsWith()`/`endsWith()`, `assertStringStartsNotWith`/`EndsNotWith`β†’`notStartsWith()`/`notEndsWith()`, `assertStringContainsString`/`NotContainsString`β†’`Assert::string()->contains()`/`notContains()`, `assertStringContainsStringIgnoringCase`/`NotContainsStringIgnoringCase`β†’`Assert::string()->ignoringCase()->contains()`/`notContains()`, `assertStringContainsStringIgnoringLineEndings`β†’`Assert::string()->ignoringLineEndings()->contains()`, `assertStringEqualsStringIgnoringLineEndings`β†’`->ignoringLineEndings()->same()`, `assertStringEqualsStringIgnoringWhitespace`/`NotEquals`β†’`->ignoringWhitespace(lineBreaks: true)->same()`/`notSame()`, `assertEqualsIgnoringCase`/`NotEqualsIgnoringCase`β†’`->ignoringCase()->same()`/`notSame()` (both sides strings), `assertMatchesRegularExpression`/`DoesNotMatchRegularExpression` (and the PHPUnit 9 `assertRegExp`/`assertNotRegExp`)β†’`Assert::string()->matchesRegex()`/`notMatchesRegex()`, `assertNotContains`β†’`Assert::iterable()->notContains()`, `assertObjectHasProperty`β†’`Assert::object()->hasProperty()`, `assertIsList`β†’`Assert::array()->isList()`, `assertContainsOnlyInt`/`String`/…→`Assert::iterable()->allOf('int')` and `assertContainsOnlyInstancesOf`β†’`allInstanceOf()`, `assertSameSize`β†’`Assert::iterable()->sameSizeAs()` (`Countable` sides), `assertJson`β†’`Assert::json()` (JSON equality stays: PHPUnit compares canonical re-encoded JSON), `assertIs`β†’the type head (`assertIsCallable`β†’`Assert::callable()` included; `assertIsBool`β†’the flat `Assert::bool()`), and the predicate checks (`assertIsScalar`, `assertIsNot*`, `assertFileExists`, `assertDirectoryExists`, …)β†’`Assert::true|false(\is_bool($x))`, also the file/directory permission checks (`\file_exists($f) && !\is_readable($f)`, side-effect-free path), `assertFinite`/`Infinite`/`Nan` (`int|float` subject), the resource checks over `\gettype()` (a closed resource counts), `assertNotInstanceOf`β†’`Assert::false($x instanceof Foo)` and `assertObjectNotHasProperty`β†’`Assert::false(\property_exists($o, $p))` (object subject). Each is a 1:1 statement rewrite; `MergeAssertChainRector` then folds adjacent same-head chains that open with the same comparison modifiers. General coalescing of arbitrary independent `assert*` lines remains impractical* | 🧩 *function host now exists; `ExpectToAssertRector` still leaves negated `->not->` and chained `->toX()->toY()` expectations untouched β€” mappable next* | | **Exception expectation (bare)** | βœ… *bare `\Testo\Expect::exception($c)` β†’ `$this->expectException($c)` (`ExpectExceptionToPhpUnitRector`); the attribute form `#[\Testo\Assert\ExpectException($c)]` β†’ prepended `$this->expectException($c)` (`ExpectExceptionAttributeToPhpUnitRector`)* | 🟑 | βœ… *`TestCallToFunctionRector` folds `->throws(X::class)` into a prepended `\Testo\Expect::exception(X)` + `never` return type* | -| **Exception message/code (fluent)** `withMessage/withCode` ↔ `expectExceptionMessage/Code` | βœ… *`ExpectExceptionToPhpUnitRector` expands one chain into several statements (`withMessageContaining`β†’`expectExceptionMessage`, `withCode`β†’`expectExceptionCode`, regex `withMessageMatchingRegex` (and the deprecated `withMessagePattern`)β†’`expectExceptionMessageMatches`); the exact `withMessage` also maps to `expectExceptionMessage` and loosens into a substring check; two modifiers on the same PHPUnit call abort the chain* | βœ… *`ExpectExceptionToTestoRector` preserves exact-empty, substring, last-wins and named message/code expectations; dynamic messages use quoted replaceable regexes. Across assignments a local retains registration and argument snapshots. A dynamic/repeated message plus an independent regex remains manual* | 🟑 *`->throws(X, 'msg')`'s second arg folds to `->withMessageContaining('msg')`; Pest has no exception-code modifier to map* | +| **Exception message/code (fluent)** `withMessage/withCode` ↔ `expectExceptionMessage/Code` | βœ… *`ExpectExceptionToPhpUnitRector` expands one chain into several statements (`withMessageContaining`β†’`expectExceptionMessage`, `withCode`β†’`expectExceptionCode`, regex `withMessageMatchingRegex` (and the deprecated `withMessagePattern`)β†’`expectExceptionMessageMatches`); the exact `withMessage` also maps to `expectExceptionMessage` and loosens into a substring check; two modifiers on the same PHPUnit call abort the chain* | βœ… *`ExpectExceptionToTestoRector` preserves exact-empty, substring, last-wins and named message/code expectations; dynamic/repeated messages use replaceable `withMessage(..., exact: ...)` without regex length limits. Across assignments a local retains registration and argument snapshots. Message regexes remain independent* | 🟑 *`->throws(X, 'msg')`'s second arg folds to `->withMessageContaining('msg')`; Pest has no exception-code modifier to map* | | **Exception message by regex** (`expectExceptionMessageMatches`) | βœ… *`withMessageMatchingRegex`/`withMessagePattern`β†’`expectExceptionMessageMatches`* | βœ… *`ExpectExceptionToTestoRector` folds it into `->withMessageMatchingRegex()`* | βž– | | **Skip at runtime** (`throw SkipTest` ↔ `markTestSkipped`) | βœ… | βœ… *`MarkTestSkippedToTestoRector` takes what the attribute form below leaves: a guarded call, a call deeper in the body, a non-literal message* | βž– *every Pest `->skip()` is unconditional β€” see the row below* | | **Skip declaratively** (`#[Skip]`) | βœ… *`SkipAttributeToPhpUnitRector`: PHPUnit has no skip attribute, so `#[\Testo\Skip('reason')]` becomes a leading `$this->markTestSkipped('reason')` and the attribute is dropped; a class-level attribute is fanned out onto each test method (a method's own reason wins). **Residual:** the skip turns into a runtime one β€” PHPUnit runs `setUp()` and the data provider before aborting, where Testo keeps the test out of the pipeline entirely* | βœ… *`MarkTestSkippedToSkipAttributeRector`: an unconditional `markTestSkipped('literal')` opening a test method β†’ `#[\Testo\Skip('literal')]`, dropping the call. A guarded/non-literal one stays a throw (row above); a call opening `setUp()` stays a throw too, rather than becoming a class-level `#[Skip]`* | βœ… *`->skip()` / `->skip('reason')` β†’ `#[\Testo\Skip('reason')]`; a conditional `->skip(fn () => …)` / `->skip($bool, 'reason')` leaves the whole statement untouched* | diff --git a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector.php b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector.php index 9514524a..718cdf42 100644 --- a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector.php +++ b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector.php @@ -6,6 +6,7 @@ use PhpParser\Node; use PhpParser\Node\Arg; +use PhpParser\Node\ArrayItem; use PhpParser\Node\Expr; use PhpParser\Node\Expr\Array_; use PhpParser\Node\Expr\ArrowFunction; @@ -17,7 +18,6 @@ use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\StaticCall; -use PhpParser\Node\Expr\Ternary; use PhpParser\Node\Expr\Variable; use PhpParser\Node\Identifier; use PhpParser\Node\Name; @@ -45,7 +45,7 @@ * chained modifier and removed: * - a single nonempty literal message => `->withMessageContaining($m)`; * - an empty literal message => `->withMessage('')`; - * - repeated or dynamic messages => a quoted, replaceable `->withMessageMatchingRegex()`; + * - repeated or dynamic messages => replaceable `->withMessage($m, exact: $m === '')`; * - `expectExceptionMessageMatches($re)` => `->withMessageMatchingRegex($re)`; * - `expectExceptionCode($n)` => `->withCode($n)`. * @@ -60,14 +60,12 @@ * {@see StmtsAwareInterface} node and rewrites its `->stmts`. * * Arguments keep their evaluation order, including overwritten expectations. A typed closure - * converts a dynamic message to a literal substring pattern (exact-empty for an empty value). + * chooses exact-empty or substring mode after evaluating a dynamic message once. * Named source arguments are validated and emitted positionally. Across assignments, a fresh local * holds the handler so registration and argument snapshots stay at their original statements. * Any other statement ends the run. A bare message/code with no preceding class is left untouched. * - * PHPUnit has independent message and regex fields. A single literal message can coexist with - * a regex in Testo; a dynamic/repeated message plus an independent regex cannot share Testo's one - * replaceable regex field and the whole run is left for manual migration. + * Message and regex expectations remain independent, as in PHPUnit. * * Only folds inside a class: the expectations belong to a test method, so a run in a free function * or at namespace level is left untouched. @@ -150,9 +148,7 @@ public function refactor(Node $node): ?Node $modifiers = []; $pending = []; $hasAssignments = false; - $hasPattern = false; $messageCount = 0; - $literalMessage = false; for ($j = $i + 1; $j < $count; ++$j) { $next = $stmts[$j]; $modifier = $next instanceof Expression ? $this->matchModifier($next) : null; @@ -160,9 +156,7 @@ public function refactor(Node $node): ?Node $hasAssignments = $hasAssignments || $pending !== []; \array_push($modifiers, ...$pending); $modifiers[] = [$next, $modifier]; - $hasPattern = $hasPattern || $modifier[0] === 'withMessageMatchingRegex'; $messageCount += (int) ($modifier[0] === 'message'); - $literalMessage = $literalMessage || ($modifier[0] === 'message' && $modifier[1] instanceof String_); $pending = []; $i = $j; continue; @@ -176,13 +170,6 @@ public function refactor(Node $node): ?Node } \assert($stmt instanceof Expression); - if ($messageCount > 0 && $hasPattern && ($messageCount !== 1 || !$literalMessage)) { - # There is only one replaceable regex slot in Testo. Do not conflate PHPUnit's - # independent fields, or register a second handler that could consume a failure - # from the first when the expected class is Throwable/Exception. - \array_push($result, ...\array_slice($stmts, $start, $i - $start + 1)); - continue; - } if ($hasAssignments) { # Keep registration and every argument evaluation at its original statement. $expectation = $this->temporaryName($node); @@ -293,38 +280,34 @@ private function argument(MethodCall|StaticCall $call, string $parameter): ?Expr private function modifier(Expr $receiver, string $method, Expr $value, int $messageCount): MethodCall { + $arguments = [new Arg($value)]; if ($method === 'message') { if ($messageCount === 1 && $value instanceof String_) { $method = $value->value === '' ? 'withMessage' : 'withMessageContaining'; } else { - $method = 'withMessageMatchingRegex'; - $value = $this->messagePattern($value); + $method = 'withMessage'; + $arguments = $value instanceof String_ + ? [new Arg($value), new Arg(new ConstFetch(new Name($value->value === '' ? 'true' : 'false')), name: new Identifier('exact'))] + : [$this->messageArguments($value)]; } } - return new MethodCall($receiver, new Identifier($method), [new Arg($value)]); + return new MethodCall($receiver, new Identifier($method), $arguments); } - private function messagePattern(Expr $message): Expr + private function messageArguments(Expr $message): Arg { - if ($message instanceof String_) { - return new String_($message->value === '' ? '~\\A\\z~' : '~' . \preg_quote($message->value, '~') . '~'); - } - # A typed closure keeps PHPUnit's string parameter boundary (weak/strict caller coercion) # and evaluates the original expression only once, including an overwritten expectation. $value = new Variable('message'); - return new FuncCall(new ArrowFunction([ + return new Arg(new FuncCall(new ArrowFunction([ 'static' => true, 'params' => [new Param($value, type: new Identifier('string'))], - 'returnType' => new Identifier('string'), - 'expr' => new Ternary( - new Identical($value, new String_('')), - new String_('~\\A\\z~'), - new Concat(new Concat(new String_('~'), new FuncCall(new FullyQualified('preg_quote'), [ - new Arg($value), new Arg(new String_('~')), - ])), new String_('~')), - ), - ]), [new Arg($message)]); + 'returnType' => new Identifier('array'), + 'expr' => new Array_([ + new ArrayItem($value), + new ArrayItem(new Identical($value, new String_(''))), + ]), + ]), [new Arg($message)]), unpack: true); } private function temporaryName(Node $node): ?Variable diff --git a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/fold_across_assignment.php.inc b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/fold_across_assignment.php.inc index 6053219c..f0dde218 100644 --- a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/fold_across_assignment.php.inc +++ b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/fold_across_assignment.php.inc @@ -20,7 +20,7 @@ class FoldAcrossAssignmentTest { $testoExpectedException = \Testo\Expect::exception(\InvalidArgumentException::class); $message = 'Type "float" is not supported. The valid types are: ' . self::TYPES . '.'; - $testoExpectedException->withMessageMatchingRegex((static fn(string $message): string => $message === '' ? '~\A\z~' : '~' . \preg_quote($message, '~') . '~')($message)); + $testoExpectedException->withMessage(...(static fn(string $message): array => [$message, $message === ''])($message)); new Compare(type: 'float'); } diff --git a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/independent_dynamic_constraints.php.inc b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/independent_dynamic_constraints.php.inc new file mode 100644 index 00000000..57f66193 --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/independent_dynamic_constraints.php.inc @@ -0,0 +1,34 @@ +expectException(\Throwable::class); + $this->expectExceptionMessage('old'); + $this->expectExceptionMessageMatches('~two~'); + $this->expectExceptionMessage($this->message()); + } + + public function dynamicMessageAndPattern(): void + { + $this->expectException(\Throwable::class); + $this->expectExceptionMessage($this->message()); + $this->expectExceptionMessageMatches('~two~'); + } +} +----- +withMessage('old', exact: false)->withMessageMatchingRegex('~two~')->withMessage(...(static fn(string $message): array => [$message, $message === ''])($this->message())); + } + + public function dynamicMessageAndPattern(): void + { + \Testo\Expect::exception(\Throwable::class)->withMessage(...(static fn(string $message): array => [$message, $message === ''])($this->message()))->withMessageMatchingRegex('~two~'); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/independent_dynamic_constraints_unchanged.php.inc b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/independent_dynamic_constraints_unchanged.php.inc deleted file mode 100644 index ce843862..00000000 --- a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/independent_dynamic_constraints_unchanged.php.inc +++ /dev/null @@ -1,19 +0,0 @@ -expectException(\Throwable::class); - $this->expectExceptionMessage('old'); - $this->expectExceptionMessageMatches('~two~'); - $this->expectExceptionMessage($this->message()); - } - - public function dynamicMessageAndPattern(): void - { - $this->expectException(\Throwable::class); - $this->expectExceptionMessage($this->message()); - $this->expectExceptionMessageMatches('~two~'); - } -} diff --git a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/runtime_contracts.php.inc b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/runtime_contracts.php.inc index c245e943..444e34bc 100644 --- a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/runtime_contracts.php.inc +++ b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/runtime_contracts.php.inc @@ -107,6 +107,65 @@ final class ExceptionRuntimeContracts throw new \RuntimeException('two'); } + public function longDynamicMessage(): void + { + $message = str_repeat('a', 100000); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage($message); + throw new \RuntimeException('prefix ' . $message . ' suffix'); + } + + public function longRepeatedMessage(): void + { + $message = str_repeat('a', 100000); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('old'); + $this->expectExceptionMessage($message); + throw new \RuntimeException('prefix ' . $message . ' suffix'); + } + + public function longMessageMustFail(): void + { + $message = str_repeat('a', 100000); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage($message); + throw new \RuntimeException(str_repeat('a', 99999)); + } + + public function lastEmptyWins(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('old'); + $this->expectExceptionMessage(''); + throw new \RuntimeException(''); + } + + public function lastNonemptyWins(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage(''); + $this->expectExceptionMessage('new'); + throw new \RuntimeException('prefix new suffix'); + } + + public function dynamicMessageAndPattern(): void + { + $message = 'two'; + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage($message); + $this->expectExceptionMessageMatches('~^two$~'); + throw new \RuntimeException('two'); + } + + public function repeatedMessageAndPatternMustFail(): void + { + $this->expectException(\Throwable::class); + $this->expectExceptionMessage('old'); + $this->expectExceptionMessageMatches('~^three$~'); + $this->expectExceptionMessage($this->mark('two')); + throw new \RuntimeException('two'); + } + private function mark(string $value): string { $this->trace[] = $value; @@ -140,7 +199,7 @@ final class ExceptionRuntimeContracts public function lastMessageWins(): void { - \Testo\Expect::exception(\RuntimeException::class)->withMessageMatchingRegex('~one~')->withMessageMatchingRegex('~two~'); + \Testo\Expect::exception(\RuntimeException::class)->withMessage('one', exact: false)->withMessage('two', exact: false); throw new \RuntimeException('two'); } @@ -152,27 +211,27 @@ final class ExceptionRuntimeContracts public function literalRegexCharacters(): void { - \Testo\Expect::exception(\RuntimeException::class)->withMessageMatchingRegex('~old~')->withMessageMatchingRegex('~\~\.\[x\]\+\$\\\\~'); + \Testo\Expect::exception(\RuntimeException::class)->withMessage('old', exact: false)->withMessage('~.[x]+$\\', exact: false); throw new \RuntimeException('prefix ~.[x]+$\\ suffix'); } public function dynamicEmptyMustFail(): void { - \Testo\Expect::exception(\RuntimeException::class)->withMessageMatchingRegex((static fn(string $message): string => $message === '' ? '~\A\z~' : '~' . \preg_quote($message, '~') . '~')($this->mark(''))); + \Testo\Expect::exception(\RuntimeException::class)->withMessage(...(static fn(string $message): array => [$message, $message === ''])($this->mark(''))); throw new \RuntimeException('nonempty'); } public function evaluationOrder(): void { $this->trace = []; - \Testo\Expect::exception($this->mark(\RuntimeException::class))->withMessageMatchingRegex((static fn(string $message): string => $message === '' ? '~\A\z~' : '~' . \preg_quote($message, '~') . '~')($this->mark('one')))->withMessageMatchingRegex((static fn(string $message): string => $message === '' ? '~\A\z~' : '~' . \preg_quote($message, '~') . '~')($this->mark('two')))->withCode(1)->withCode(2); + \Testo\Expect::exception($this->mark(\RuntimeException::class))->withMessage(...(static fn(string $message): array => [$message, $message === ''])($this->mark('one')))->withMessage(...(static fn(string $message): array => [$message, $message === ''])($this->mark('two')))->withCode(1)->withCode(2); \Testo\Assert::same($this->trace, [\RuntimeException::class, 'one', 'two']); throw new \RuntimeException('two', 2); } public function overwrittenArgumentThrows(): void { - \Testo\Expect::exception(\RuntimeException::class)->withMessageMatchingRegex('~one~')->withMessageMatchingRegex((static fn(string $message): string => $message === '' ? '~\A\z~' : '~' . \preg_quote($message, '~') . '~')($this->throwOne())); + \Testo\Expect::exception(\RuntimeException::class)->withMessage('one', exact: false)->withMessage(...(static fn(string $message): array => [$message, $message === ''])($this->throwOne())); } public function independentPattern(): void @@ -192,10 +251,10 @@ final class ExceptionRuntimeContracts $class = \RuntimeException::class; $message = 'one'; $testoExpectedException = \Testo\Expect::exception($class); - $testoExpectedException->withMessageMatchingRegex((static fn(string $message): string => $message === '' ? '~\A\z~' : '~' . \preg_quote($message, '~') . '~')($message)); + $testoExpectedException->withMessage(...(static fn(string $message): array => [$message, $message === ''])($message)); $class = \LogicException::class; $message = 'two'; - $testoExpectedException->withMessageMatchingRegex((static fn(string $message): string => $message === '' ? '~\A\z~' : '~' . \preg_quote($message, '~') . '~')($message)); + $testoExpectedException->withMessage(...(static fn(string $message): array => [$message, $message === ''])($message)); throw new \RuntimeException('two'); } @@ -204,11 +263,57 @@ final class ExceptionRuntimeContracts $testoExpectedException = 'source'; $testoExpectedException2 = \Testo\Expect::exception(\RuntimeException::class); $message = 'two'; - $testoExpectedException2->withMessageMatchingRegex((static fn(string $message): string => $message === '' ? '~\A\z~' : '~' . \preg_quote($message, '~') . '~')($message)); + $testoExpectedException2->withMessage(...(static fn(string $message): array => [$message, $message === ''])($message)); \Testo\Assert::same($testoExpectedException, 'source'); throw new \RuntimeException('two'); } + public function longDynamicMessage(): void + { + $message = str_repeat('a', 100000); + \Testo\Expect::exception(\RuntimeException::class)->withMessage(...(static fn(string $message): array => [$message, $message === ''])($message)); + throw new \RuntimeException('prefix ' . $message . ' suffix'); + } + + public function longRepeatedMessage(): void + { + $message = str_repeat('a', 100000); + \Testo\Expect::exception(\RuntimeException::class)->withMessage('old', exact: false)->withMessage(...(static fn(string $message): array => [$message, $message === ''])($message)); + throw new \RuntimeException('prefix ' . $message . ' suffix'); + } + + public function longMessageMustFail(): void + { + $message = str_repeat('a', 100000); + \Testo\Expect::exception(\RuntimeException::class)->withMessage(...(static fn(string $message): array => [$message, $message === ''])($message)); + throw new \RuntimeException(str_repeat('a', 99999)); + } + + public function lastEmptyWins(): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessage('old', exact: false)->withMessage('', exact: true); + throw new \RuntimeException(''); + } + + public function lastNonemptyWins(): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessage('', exact: true)->withMessage('new', exact: false); + throw new \RuntimeException('prefix new suffix'); + } + + public function dynamicMessageAndPattern(): void + { + $message = 'two'; + \Testo\Expect::exception(\RuntimeException::class)->withMessage(...(static fn(string $message): array => [$message, $message === ''])($message))->withMessageMatchingRegex('~^two$~'); + throw new \RuntimeException('two'); + } + + public function repeatedMessageAndPatternMustFail(): void + { + \Testo\Expect::exception(\Throwable::class)->withMessage('old', exact: false)->withMessageMatchingRegex('~^three$~')->withMessage(...(static fn(string $message): array => [$message, $message === ''])($this->mark('two'))); + throw new \RuntimeException('two'); + } + private function mark(string $value): string { $this->trace[] = $value; diff --git a/bridge/rector/src/PhpunitToTesto/TODO.md b/bridge/rector/src/PhpunitToTesto/TODO.md index cda125ec..71067b21 100644 --- a/bridge/rector/src/PhpunitToTesto/TODO.md +++ b/bridge/rector/src/PhpunitToTesto/TODO.md @@ -46,12 +46,12 @@ Mocks are out of this set: they convert through `phpunit-to-double` or `phpunit- - **ExpectExceptionToTestoRector** (registered) β€” folds sibling class/message/regex/code expectations while preserving argument evaluation order and last-wins messages/codes. A single nonempty literal uses `withMessageContaining()`, an empty literal uses exact `withMessage('')`, - and dynamic/repeated messages use a quoted, replaceable regex (exact-empty for `''`). Named source + and dynamic/repeated messages use replaceable `withMessage($message, exact: $message === '')` + without regex length limits. A typed closure evaluates each dynamic argument once. Named source parameters are validated and emitted positionally. Across assignments a fresh local holds the handler, keeping registration and argument snapshots at the original statements. Other statements - end the run. A single literal message and an independent regex both convert; a dynamic/repeated - message plus an independent regex stays **entirely unchanged for manual migration**, since Testo - has only one replaceable regex slot. Variable variables also prevent introducing a handler local. + end the run. Message and regex expectations retain independent fields, including dynamic and + repeated messages. Variable variables prevent introducing a handler local. A bare message/code with no preceding class expectation remains manual. - **GroupToTestoRector** (registered) β€” collapses every PHPUnit group source on a node β€” the `@group` docblock annotation(s) **and** the repeatable single-name `#[Group]` attribute(s) β€” into diff --git a/bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector.php b/bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector.php index 1ac0a4d1..fdb58531 100644 --- a/bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector.php +++ b/bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector.php @@ -7,10 +7,12 @@ use PhpParser\Node; use PhpParser\Node\Arg; use PhpParser\Node\Expr; +use PhpParser\Node\Expr\ConstFetch; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Expr\Variable; use PhpParser\Node\Identifier; +use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt\Expression; use PHPStan\Analyser\Scope; use Rector\NodeTypeResolver\Node\AttributeKey; @@ -147,10 +149,31 @@ public function refactor(Node $node): ?array */ private function mapModifier(string $modifier, array $args): ?Expression { - $first = ($args[0] ?? null) instanceof Arg ? $args[0]->value : null; - if ($first === null) { + $argument = $args[0] ?? null; + if (!$argument instanceof Arg || $argument->unpack) { return null; } + $first = $argument->value; + + if ($modifier === 'withMessage' && \count($args) > 1) { + $exact = $args[1]; + if (\count($args) !== 2 || !$exact instanceof Arg || $exact->unpack + || ($argument->name !== null && !$this->isName($argument->name, 'message')) + || ($exact->name !== null && !$this->isName($exact->name, 'exact')) + || !$exact->value instanceof ConstFetch + || (!$this->isName($exact->value->name, 'true') && !$this->isName($exact->value->name, 'false')) + ) { + return null; + } + if ($this->isName($exact->value->name, 'false')) { + if (!$first instanceof String_) { + return null; + } + if ($first->value === '') { + return $this->expectStmt('expectExceptionMessageMatches', [$this->arg(new String_('~~'))]); + } + } + } return match ($modifier) { 'withMessage', 'withMessageContaining' => $this->expectStmt('expectExceptionMessage', [$this->arg($first)]), diff --git a/bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector/dynamic_message_mode_unchanged.php.inc b/bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector/dynamic_message_mode_unchanged.php.inc new file mode 100644 index 00000000..88c82eb5 --- /dev/null +++ b/bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector/dynamic_message_mode_unchanged.php.inc @@ -0,0 +1,11 @@ +withMessage($message, exact: false); + \Testo\Expect::exception(\RuntimeException::class)->withMessage('message', exact: $exact); + \Testo\Expect::exception(\RuntimeException::class)->withMessage(...$arguments); + } +} diff --git a/bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector/with_message_mode.php.inc b/bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector/with_message_mode.php.inc new file mode 100644 index 00000000..ae814cf2 --- /dev/null +++ b/bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector/with_message_mode.php.inc @@ -0,0 +1,31 @@ +withMessage('boom', exact: false); + } + + public function emptySubstring(): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessage('', exact: false); + } +} +----- +expectException(\RuntimeException::class); + $this->expectExceptionMessage('boom'); + } + + public function emptySubstring(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('~~'); + } +} diff --git a/bridge/rector/src/TestoToPhpunit/TODO.md b/bridge/rector/src/TestoToPhpunit/TODO.md index 53a085fc..3ba84c07 100644 --- a/bridge/rector/src/TestoToPhpunit/TODO.md +++ b/bridge/rector/src/TestoToPhpunit/TODO.md @@ -44,6 +44,9 @@ directory but are **not** registered in `config/testo-to-phpunit.php`. `expectExceptionMessage` and so loosens into a substring check. A chain with an unmapped modifier (`fromMethod`/`withPrevious`/`withoutPrevious`) or with two modifiers landing on the same PHPUnit call (PHPUnit keeps only the last) aborts the whole conversion and is left untouched for manual review. + A literal `withMessage('value', exact: false)` maps to `expectExceptionMessage`; an empty substring + maps to the always-matching `expectExceptionMessageMatches('~~')`. Dynamic comparison modes or + unpacked arguments stay manual so their empty-string semantics and evaluations are not lost. - **`ExpectExceptionAttributeToPhpUnitRector`** (registered) β€” converts the attribute form `#[\Testo\Assert\ExpectException($class)]` into a `$this->expectException($class);` statement prepended to the method body (PHPUnit dropped its `ExpectException` attribute, leaving only the diff --git a/bridge/rector/tests/Unit/ExceptionExpectationRuntimeTest.php b/bridge/rector/tests/Unit/ExceptionExpectationRuntimeTest.php index f437c722..b275411f 100644 --- a/bridge/rector/tests/Unit/ExceptionExpectationRuntimeTest.php +++ b/bridge/rector/tests/Unit/ExceptionExpectationRuntimeTest.php @@ -42,7 +42,7 @@ public function convertedExpectationsKeepTheirVerdictsAndEvaluationOrder(): void } $totals = $result->summary; - Assert::same([$totals->total(), $totals->metric('assertions'), $totals->count(Status::Passed), $totals->count(Status::Failed)], [12, 14, 9, 3]); + Assert::same([$totals->total(), $totals->metric('assertions'), $totals->count(Status::Passed), $totals->count(Status::Failed)], [19, 21, 14, 5]); $failures = []; $assertions = []; foreach ($result as $suite) { @@ -59,6 +59,8 @@ public function convertedExpectationsKeepTheirVerdictsAndEvaluationOrder(): void 'ExceptionRuntimeContracts::emptyMustFail', 'ExceptionRuntimeContracts::dynamicEmptyMustFail', 'ExceptionRuntimeContracts::independentPatternMustFail', + 'ExceptionRuntimeContracts::longMessageMustFail', + 'ExceptionRuntimeContracts::repeatedMessageAndPatternMustFail', ]); Assert::same($assertions['evaluationOrder'], 2); Assert::same($assertions['independentPattern'], 1); diff --git a/plugin/assert/src/Api/ExpectedException.php b/plugin/assert/src/Api/ExpectedException.php index 1525f2b2..f46539cd 100644 --- a/plugin/assert/src/Api/ExpectedException.php +++ b/plugin/assert/src/Api/ExpectedException.php @@ -31,11 +31,12 @@ interface ExpectedException public function fromMethod(string $class, string $method): static; /** - * The expected exception should have the exact message. + * The expected exception should have the given message. * - * Subsequent calls replace the previous expectation. + * Exact by default; with `$exact = false`, the message must contain the given substring. + * Subsequent calls replace both the message and its comparison mode. */ - public function withMessage(string $message): static; + public function withMessage(string $message, bool $exact = true): static; /** * The expected exception message should match the given regular expression. diff --git a/plugin/assert/src/Internal/Expectation/ExpectExceptionHandler.php b/plugin/assert/src/Internal/Expectation/ExpectExceptionHandler.php index 82a600ee..81ca52d0 100644 --- a/plugin/assert/src/Internal/Expectation/ExpectExceptionHandler.php +++ b/plugin/assert/src/Internal/Expectation/ExpectExceptionHandler.php @@ -26,6 +26,7 @@ final class ExpectExceptionHandler implements ExpectedException private array $fromMethod = []; private ?string $expectedMessage = null; + private bool $expectedMessageExact = true; /** @var non-empty-string|null */ private ?string $expectedMessagePattern = null; @@ -110,12 +111,13 @@ public function fromMethod(string $class, string $method): static } /** - * The expected exception should have the exact message. + * The expected exception message should match exactly or contain the given substring. */ #[\Override] - public function withMessage(string $message): static + public function withMessage(string $message, bool $exact = true): static { $this->expectedMessage = $message; + $this->expectedMessageExact = $exact; return $this; } @@ -247,10 +249,14 @@ private function evaluate(?\Throwable $actual): Expectation|\Throwable # Message check if ($this->expectedMessage !== null) { - $actual->getMessage() === $this->expectedMessage - ? $composite->success('message is "' . $this->expectedMessage . '"') + $label = ($this->expectedMessageExact ? 'message is "' : 'message contains "') . $this->expectedMessage . '"'; + $matches = $this->expectedMessageExact + ? $actual->getMessage() === $this->expectedMessage + : \str_contains($actual->getMessage(), $this->expectedMessage); + $matches + ? $composite->success($label) : $composite->fail( - expectation: 'message is "' . $this->expectedMessage . '"', + expectation: $label, reason: 'got "' . $actual->getMessage() . '"', ); } diff --git a/plugin/assert/tests/Feature/ExpectExceptionNegativeTest.php b/plugin/assert/tests/Feature/ExpectExceptionNegativeTest.php index 79ab7712..2bea8f29 100644 --- a/plugin/assert/tests/Feature/ExpectExceptionNegativeTest.php +++ b/plugin/assert/tests/Feature/ExpectExceptionNegativeTest.php @@ -58,6 +58,24 @@ public function wrongMessage(): void ->contains('got "actual message"'); } + public function wrongSubstringMessage(): void + { + $result = TestRunner::runTest([ExpectExceptionNegative::class, 'wrongSubstringMessage']); + + Assert::same($result->status, Status::Failed); + Assert::instanceOf($result->failure, Expectation::class); + Assert::string($result->failure->getFailReason())->contains('message contains "needle"'); + } + + public function exactModeIsRestored(): void + { + $result = TestRunner::runTest([ExpectExceptionNegative::class, 'exactModeIsRestored']); + + Assert::same($result->status, Status::Failed); + Assert::instanceOf($result->failure, Expectation::class); + Assert::string($result->failure->getFailReason())->contains('message is "expected"'); + } + public function failureMessageCarriesTheReason(): void { $result = TestRunner::runTest([ExpectExceptionNegative::class, 'wrongMessage']); diff --git a/plugin/assert/tests/Self/ExpectExceptionTest.php b/plugin/assert/tests/Self/ExpectExceptionTest.php index 261350e0..c1be9b7c 100644 --- a/plugin/assert/tests/Self/ExpectExceptionTest.php +++ b/plugin/assert/tests/Self/ExpectExceptionTest.php @@ -157,6 +157,43 @@ public function messageContaining(): never throw new \RuntimeException('File config.yml not found'); } + #[Test] + public function substringMessageReplacesEarlierExactMessage(): never + { + Expect::exception(\RuntimeException::class) + ->withMessage('old') + ->withMessage('new', exact: false); + + throw new \RuntimeException('prefix new suffix'); + } + + #[Test] + public function exactMessageReplacesEarlierSubstring(): never + { + Expect::exception(\RuntimeException::class) + ->withMessage('old', exact: false) + ->withMessage('new'); + + throw new \RuntimeException('new'); + } + + #[Test] + public function emptySubstringAcceptsANonemptyMessage(): never + { + Expect::exception(\RuntimeException::class)->withMessage('', exact: false); + + throw new \RuntimeException('nonempty'); + } + + #[Test] + public function longSubstringMessage(): never + { + $message = \str_repeat('a', 100000); + Expect::exception(\RuntimeException::class)->withMessage($message, exact: false); + + throw new \RuntimeException('prefix ' . $message . ' suffix'); + } + #[Test] public function previous(): void { diff --git a/plugin/assert/tests/Stub/ExpectExceptionNegative.php b/plugin/assert/tests/Stub/ExpectExceptionNegative.php index 5d14d9ad..f2820e04 100644 --- a/plugin/assert/tests/Stub/ExpectExceptionNegative.php +++ b/plugin/assert/tests/Stub/ExpectExceptionNegative.php @@ -81,6 +81,24 @@ public function wrongMessageContaining(): never throw new \RuntimeException('haystack without the word'); } + #[Test] + public function wrongSubstringMessage(): never + { + Expect::exception(\RuntimeException::class)->withMessage('needle', exact: false); + + throw new \RuntimeException('haystack'); + } + + #[Test] + public function exactModeIsRestored(): never + { + Expect::exception(\RuntimeException::class) + ->withMessage('old', exact: false) + ->withMessage('expected'); + + throw new \RuntimeException('prefix expected'); + } + /** * Wrong message and wrong code at once. */ 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 da132958..6db3112e 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 @@ -62,7 +62,7 @@ the assertion **argument order flips** (see the pitfalls), and discovery is attr | `$this->assertIsResource($x)` / `assertIsClosedResource($x)` | `Assert::true(\str_starts_with(\gettype($x), 'resource'))` / `Assert::same(\gettype($x), 'resource (closed)')` β€” PHPUnit counts a closed resource, `is_resource()` does not. | | `$this->assertJson($s)` | `Assert::json($s)` (no `$message` arg). The JSON equality assertions have no counterpart: PHPUnit compares canonical re-encoded JSON (`1` β‰  `1.0`, `{}` β‰  `[]`), which `Assert::equals()` over decoded values does not. | | `$this->expectException(X::class)` before Act | `Expect::exception(X::class)->withCode(...)` before Act. Method return type becomes `never`. | -| `$this->expectExceptionMessage('...')` | A single nonempty literal uses `->withMessageContaining('...')`; `''` uses exact `->withMessage('')`. Dynamic or repeated messages use a quoted `withMessageMatchingRegex()` so the last message wins and earlier argument evaluations remain. A dynamic/repeated message combined with an independent `expectExceptionMessageMatches()` stays manual. | +| `$this->expectExceptionMessage('...')` | A single nonempty literal uses `->withMessageContaining('...')`; `''` uses exact `->withMessage('')`. Dynamic or repeated messages use replaceable `withMessage($message, exact: $message === '')`, evaluating each expression once. Substrings use `str_contains()` without regex length limits; independent message regexes stay separate. | | `$this->expectExceptionMessageMatches('/.../')` | `->withMessageMatchingRegex('/.../')`. | | `$this->markTestSkipped('reason')` | `#[Skip('reason')]` from **`Testo\Skip`** when the call opens the test unconditionally β€” the test then never enters the pipeline (no hooks, no provider, no retries). A guarded call, one deeper in the body, or a non-literal message stays runtime: `throw new \Testo\Core\Exception\SkipTest('reason')` from the test body. | | `$this->markTestIncomplete('reason')` | No "incomplete" status. Port to `throw new SkipTest('TODO: reason')`, or leave the body empty β†’ `Status::Risky`. | diff --git a/skills/testo-write-tests/SKILL.md b/skills/testo-write-tests/SKILL.md index e50170d3..5e24d061 100644 --- a/skills/testo-write-tests/SKILL.md +++ b/skills/testo-write-tests/SKILL.md @@ -121,6 +121,9 @@ public function rejectsNegativeAmount(): never ``` Other Expect modifiers: `withMessageContaining(...)`, `withMessageMatchingRegex(...)` (`withMessagePattern()` is its deprecated alias; the `testo-shift` Rector set of `testo/bridge-rector` renames it), `withPrevious(class, closure)`, memory-leak expectations. +`withMessage($message, exact: false)` checks a substring without regex limits; repeated calls replace +the message and comparison mode. `withMessage()` defaults back to exact matching. The separate +`withMessageContaining()` modifier accumulates substrings that must all match. Do **not** use try/catch-based assertions for expected exceptions β€” `Expect::exception` is the correct API. ## Marking a test as skipped or cancelled From 049a0318eea395c505ef5bd2c0a6abdc3d5c15e7 Mon Sep 17 00:00:00 2001 From: Ichi Date: Thu, 15 Oct 2026 17:17:35 +0500 Subject: [PATCH 4/4] fix(rector): address exception migration review findings Preserve repeated message modes in reverse migrations and reserve handler names across the containing function, including conditional and later locals. Leave extract-based dynamic locals for manual migration. Keep provably nonempty messages readable, remove adjacent overwritten literals only when safe, and pass comparison modes by name so older Assert versions reject unsupported output explicitly. Move runtime controls to the Feature suite, check individual verdicts and assertion counts, and cover weak/strict coercion and assignments that throw. Document generated-code compatibility and the public Assert API requirement. Refs #389 Assisted-By: GPT-6 --- bridge/rector/FEATURE_PARITY.md | 2 +- bridge/rector/README.md | 14 ++ .../ExpectExceptionToTestoRector.php | 67 +++++-- .../dynamic_locals_unchanged.php.inc | 12 ++ .../fold_across_assignment.php.inc | 2 +- .../independent_dynamic_constraints.php.inc | 4 +- .../message_expressions.php.inc | 78 ++++++++ .../runtime_contracts.php.inc | 170 ++++++++++++++---- .../strict_message_coercion.php.inc | 28 +++ bridge/rector/src/PhpunitToTesto/TODO.md | 18 +- .../ExpectExceptionToPhpUnitRector.php | 9 +- .../repeated_message_modes_unchanged.php.inc | 23 +++ bridge/rector/src/TestoToPhpunit/TODO.md | 2 + .../ExceptionExpectationRuntimeTest.php | 111 ++++++++++++ .../Unit/ExceptionExpectationRuntimeTest.php | 69 ------- bridge/rector/tests/suites.php | 5 + .../references/phpunit-to-testo-map.md | 2 +- 17 files changed, 486 insertions(+), 130 deletions(-) create mode 100644 bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/dynamic_locals_unchanged.php.inc create mode 100644 bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/message_expressions.php.inc create mode 100644 bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/strict_message_coercion.php.inc create mode 100644 bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector/repeated_message_modes_unchanged.php.inc create mode 100644 bridge/rector/tests/Feature/ExceptionExpectationRuntimeTest.php delete mode 100644 bridge/rector/tests/Unit/ExceptionExpectationRuntimeTest.php diff --git a/bridge/rector/FEATURE_PARITY.md b/bridge/rector/FEATURE_PARITY.md index d5772798..53e97217 100644 --- a/bridge/rector/FEATURE_PARITY.md +++ b/bridge/rector/FEATURE_PARITY.md @@ -17,7 +17,7 @@ Conversion coverage across the three directions supported by `testo/bridge-recto | **actual/expected argument swap** | βœ… | βœ… | βœ… | | **Fluent / typed chains** (`Assert::string()->…`, Pest `->not->`, `toBeGreaterThan`) | 🟑 *`TypedAssertChainRector` decomposes into separate `assert*` lines (incl. 1β†’N `hasKeys`, `between`, `isList`β†’`assertIsList`, `sameElementsAs`β†’`assertEqualsCanonicalizing`, the `iterable` headβ†’`assertIsIterable`, a bare head such as `Assert::callable($x)`β†’`assertIsCallable`, `allInstanceOf`β†’`assertContainsOnlyInstancesOf`, `allOf('int')`β†’`assertContainsOnlyInt` for the exact native types, `sameSizeAs`β†’`assertSameSize` on countable sides, `matchesRegex`/`notMatchesRegex`β†’`assertMatchesRegularExpression`/`assertDoesNotMatchRegularExpression`, `ignoringCase()->contains()`/`notContains()`β†’`assertStringContainsStringIgnoringCase`/`assertStringNotContainsStringIgnoringCase`, `ignoringLineEndings()->contains()`β†’`assertStringContainsStringIgnoringLineEndings`, `same`/`notSame`β†’`assertSame`/`assertNotSame`, `notStartsWith`/`notEndsWith`β†’`assertStringStartsNotWith`/`assertStringEndsNotWith`, `ignoringCase()->same()`/`notSame()`β†’`assertEqualsIgnoringCase`/`assertNotEqualsIgnoringCase`, `ignoringLineEndings()->same()`β†’`assertStringEqualsStringIgnoringLineEndings`, `ignoringWhitespace(true)->same()`/`notSame()`β†’`assertStringEqualsStringIgnoringWhitespace`/`NotEquals`); a non-variable subject is hoisted into a `$value` local so it is evaluated once; only inside a class (the emitted `$this->assert*` needs a method scope); JSON path/structure, `every`, the `callable` matchers (`isStatic`/`notStatic`, `hasReturnType`), custom matchers, and the string modifier combinations PHPUnit lacks (`ignoringCase()->startsWith()`, several modes together, `ignoringWhitespace()` without line breaks, `ignoringBlankLines()`, `ignoringAnsi()`) are left untouched + TODO* | 🟑 *`TypedAssertCallToTestoRector` converts the assertions whose faithful Testo form is a typed head + matcher: comparisons (`assertGreaterThan`β†’`Assert::numeric()->greaterThan()`, …), array keys (`assertArrayHasKey`β†’`Assert::array()->hasKeys()`), `assertEqualsCanonicalizing`β†’`sameElementsAs`, array-subject `assertEmpty`/`assertNotEmpty`β†’`blank`/`notBlank` (a non-object subject β†’ `Assert::true(empty($x))`), `assertStringStartsWith`/`EndsWith`β†’`Assert::string()->startsWith()`/`endsWith()`, `assertStringStartsNotWith`/`EndsNotWith`β†’`notStartsWith()`/`notEndsWith()`, `assertStringContainsString`/`NotContainsString`β†’`Assert::string()->contains()`/`notContains()`, `assertStringContainsStringIgnoringCase`/`NotContainsStringIgnoringCase`β†’`Assert::string()->ignoringCase()->contains()`/`notContains()`, `assertStringContainsStringIgnoringLineEndings`β†’`Assert::string()->ignoringLineEndings()->contains()`, `assertStringEqualsStringIgnoringLineEndings`β†’`->ignoringLineEndings()->same()`, `assertStringEqualsStringIgnoringWhitespace`/`NotEquals`β†’`->ignoringWhitespace(lineBreaks: true)->same()`/`notSame()`, `assertEqualsIgnoringCase`/`NotEqualsIgnoringCase`β†’`->ignoringCase()->same()`/`notSame()` (both sides strings), `assertMatchesRegularExpression`/`DoesNotMatchRegularExpression` (and the PHPUnit 9 `assertRegExp`/`assertNotRegExp`)β†’`Assert::string()->matchesRegex()`/`notMatchesRegex()`, `assertNotContains`β†’`Assert::iterable()->notContains()`, `assertObjectHasProperty`β†’`Assert::object()->hasProperty()`, `assertIsList`β†’`Assert::array()->isList()`, `assertContainsOnlyInt`/`String`/…→`Assert::iterable()->allOf('int')` and `assertContainsOnlyInstancesOf`β†’`allInstanceOf()`, `assertSameSize`β†’`Assert::iterable()->sameSizeAs()` (`Countable` sides), `assertJson`β†’`Assert::json()` (JSON equality stays: PHPUnit compares canonical re-encoded JSON), `assertIs`β†’the type head (`assertIsCallable`β†’`Assert::callable()` included; `assertIsBool`β†’the flat `Assert::bool()`), and the predicate checks (`assertIsScalar`, `assertIsNot*`, `assertFileExists`, `assertDirectoryExists`, …)β†’`Assert::true|false(\is_bool($x))`, also the file/directory permission checks (`\file_exists($f) && !\is_readable($f)`, side-effect-free path), `assertFinite`/`Infinite`/`Nan` (`int|float` subject), the resource checks over `\gettype()` (a closed resource counts), `assertNotInstanceOf`β†’`Assert::false($x instanceof Foo)` and `assertObjectNotHasProperty`β†’`Assert::false(\property_exists($o, $p))` (object subject). Each is a 1:1 statement rewrite; `MergeAssertChainRector` then folds adjacent same-head chains that open with the same comparison modifiers. General coalescing of arbitrary independent `assert*` lines remains impractical* | 🧩 *function host now exists; `ExpectToAssertRector` still leaves negated `->not->` and chained `->toX()->toY()` expectations untouched β€” mappable next* | | **Exception expectation (bare)** | βœ… *bare `\Testo\Expect::exception($c)` β†’ `$this->expectException($c)` (`ExpectExceptionToPhpUnitRector`); the attribute form `#[\Testo\Assert\ExpectException($c)]` β†’ prepended `$this->expectException($c)` (`ExpectExceptionAttributeToPhpUnitRector`)* | 🟑 | βœ… *`TestCallToFunctionRector` folds `->throws(X::class)` into a prepended `\Testo\Expect::exception(X)` + `never` return type* | -| **Exception message/code (fluent)** `withMessage/withCode` ↔ `expectExceptionMessage/Code` | βœ… *`ExpectExceptionToPhpUnitRector` expands one chain into several statements (`withMessageContaining`β†’`expectExceptionMessage`, `withCode`β†’`expectExceptionCode`, regex `withMessageMatchingRegex` (and the deprecated `withMessagePattern`)β†’`expectExceptionMessageMatches`); the exact `withMessage` also maps to `expectExceptionMessage` and loosens into a substring check; two modifiers on the same PHPUnit call abort the chain* | βœ… *`ExpectExceptionToTestoRector` preserves exact-empty, substring, last-wins and named message/code expectations; dynamic/repeated messages use replaceable `withMessage(..., exact: ...)` without regex length limits. Across assignments a local retains registration and argument snapshots. Message regexes remain independent* | 🟑 *`->throws(X, 'msg')`'s second arg folds to `->withMessageContaining('msg')`; Pest has no exception-code modifier to map* | +| **Exception message/code (fluent)** `withMessage/withCode` ↔ `expectExceptionMessage/Code` | βœ… *`ExpectExceptionToPhpUnitRector` expands one chain into several statements (`withMessageContaining`β†’`expectExceptionMessage`, `withCode`β†’`expectExceptionCode`, regex `withMessageMatchingRegex` (and the deprecated `withMessagePattern`)β†’`expectExceptionMessageMatches`); the exact `withMessage` also maps to `expectExceptionMessage` and loosens into a substring check; repeated `withMessage` or two constraints on the same PHPUnit call abort the chain* | βœ… *`ExpectExceptionToTestoRector` preserves exact-empty, substring, last-wins and named message/code expectations; proven nonempty messages stay simple, adjacent overwritten literals are removed, and other messages use replaceable `withMessage(..., exact: ...)` with a named flag and no regex length limit. Across assignments a local retains registration and argument snapshots. Message regexes remain independent* | 🟑 *`->throws(X, 'msg')`'s second arg folds to `->withMessageContaining('msg')`; Pest has no exception-code modifier to map* | | **Exception message by regex** (`expectExceptionMessageMatches`) | βœ… *`withMessageMatchingRegex`/`withMessagePattern`β†’`expectExceptionMessageMatches`* | βœ… *`ExpectExceptionToTestoRector` folds it into `->withMessageMatchingRegex()`* | βž– | | **Skip at runtime** (`throw SkipTest` ↔ `markTestSkipped`) | βœ… | βœ… *`MarkTestSkippedToTestoRector` takes what the attribute form below leaves: a guarded call, a call deeper in the body, a non-literal message* | βž– *every Pest `->skip()` is unconditional β€” see the row below* | | **Skip declaratively** (`#[Skip]`) | βœ… *`SkipAttributeToPhpUnitRector`: PHPUnit has no skip attribute, so `#[\Testo\Skip('reason')]` becomes a leading `$this->markTestSkipped('reason')` and the attribute is dropped; a class-level attribute is fanned out onto each test method (a method's own reason wins). **Residual:** the skip turns into a runtime one β€” PHPUnit runs `setUp()` and the data provider before aborting, where Testo keeps the test out of the pipeline entirely* | βœ… *`MarkTestSkippedToSkipAttributeRector`: an unconditional `markTestSkipped('literal')` opening a test method β†’ `#[\Testo\Skip('literal')]`, dropping the call. A guarded/non-literal one stays a throw (row above); a call opening `setUp()` stays a throw too, rather than becoming a class-level `#[Skip]`* | βœ… *`->skip()` / `->skip('reason')` β†’ `#[\Testo\Skip('reason')]`; a conditional `->skip(fn () => …)` / `->skip($bool, 'reason')` leaves the whole statement untouched* | diff --git a/bridge/rector/README.md b/bridge/rector/README.md index 2062365a..a4abe542 100644 --- a/bridge/rector/README.md +++ b/bridge/rector/README.md @@ -19,6 +19,20 @@ also work with `$rectorConfig->import(...)`. `PHPUNIT_TO_TESTO` switches Rector to serial processing. A class that reaches `TestCase` through a project base class is recognised by the base's ancestry, and a parallel worker can read that base after another worker has already detached it, leaving the subclass's tests without `#[Test]` and silently undiscovered. Do not re-enable `withParallel()` for this set. +### Generated-code compatibility + +Converting dynamic or repeated PHPUnit exception messages can generate +`withMessage($message, exact: ...)`. The target project needs a `testo/assert` release providing +`ExpectedException::withMessage(string $message, bool $exact = true)` before running that output. +While this API is unreleased, use the matching Assert code from the same `1.x` checkout. +The bridge's development dependencies do not constrain Assert in the target project, especially +when Rector is installed separately. + +The generated flag is always named, including inside an unpacked argument array. An older Assert +therefore reports an unknown `exact` parameter instead of silently ignoring a positional flag and +changing a substring check to an exact match. Upgrade the target project's Assert before migration; +keep independent regex message constraints when reviewing the result. + ### Mock sets Testo core ships no mocking, so test doubles convert through their own sets, one per target library. diff --git a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector.php b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector.php index 718cdf42..303973a2 100644 --- a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector.php +++ b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector.php @@ -19,6 +19,7 @@ use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Expr\Variable; +use PhpParser\Node\FunctionLike; use PhpParser\Node\Identifier; use PhpParser\Node\Name; use PhpParser\Node\Name\FullyQualified; @@ -43,9 +44,11 @@ * The head `$this->expectException($c)` (also `self::`/`static::`) becomes * `\Testo\Expect::exception($c)`, and each immediately-following sibling statement is absorbed as a * chained modifier and removed: - * - a single nonempty literal message => `->withMessageContaining($m)`; + * - a single provably nonempty message => `->withMessageContaining($m)`; * - an empty literal message => `->withMessage('')`; - * - repeated or dynamic messages => replaceable `->withMessage($m, exact: $m === '')`; + * - a string variable => `->withMessage($m, exact: $m === '')`; + * - retained repeated nonempty messages => `->withMessage($m, exact: false)`; + * - other messages => `->withMessage(...(static fn(string $m): array => [$m, 'exact' => $m === ''])($expr))`; * - `expectExceptionMessageMatches($re)` => `->withMessageMatchingRegex($re)`; * - `expectExceptionCode($n)` => `->withCode($n)`. * @@ -153,6 +156,16 @@ public function refactor(Node $node): ?Node $next = $stmts[$j]; $modifier = $next instanceof Expression ? $this->matchModifier($next) : null; if ($modifier !== null) { + $previous = $modifiers[\array_key_last($modifiers)] ?? null; + if ($pending === [] && $modifier[0] === 'message' && $modifier[1] instanceof String_ + && \is_array($previous) && $previous[1][0] === 'message' + && $previous[1][1] instanceof String_ && $previous[0]->getComments() === [] + ) { + # Only adjacent literals: an intervening argument or assignment may throw + # while the earlier message expectation is still active. + \array_pop($modifiers); + --$messageCount; + } $hasAssignments = $hasAssignments || $pending !== []; \array_push($modifiers, ...$pending); $modifiers[] = [$next, $modifier]; @@ -172,7 +185,7 @@ public function refactor(Node $node): ?Node \assert($stmt instanceof Expression); if ($hasAssignments) { # Keep registration and every argument evaluation at its original statement. - $expectation = $this->temporaryName($node); + $expectation = $this->temporaryName($node, $stmt); if ($expectation === null) { \array_push($result, ...\array_slice($stmts, $start, $i - $start + 1)); continue; @@ -282,13 +295,23 @@ private function modifier(Expr $receiver, string $method, Expr $value, int $mess { $arguments = [new Arg($value)]; if ($method === 'message') { - if ($messageCount === 1 && $value instanceof String_) { - $method = $value->value === '' ? 'withMessage' : 'withMessageContaining'; + # Rector's type corrector removes non-empty-string accessories; keep PHPStan's proof. + $scope = $value->getAttribute(AttributeKey::SCOPE); + $type = $scope instanceof Scope ? $scope->getType($value) : $this->getType($value); + if ($messageCount === 1 && $type->isNonEmptyString()->yes()) { + $method = 'withMessageContaining'; } else { $method = 'withMessage'; - $arguments = $value instanceof String_ - ? [new Arg($value), new Arg(new ConstFetch(new Name($value->value === '' ? 'true' : 'false')), name: new Identifier('exact'))] - : [$this->messageArguments($value)]; + if ($value instanceof String_) { + $arguments = $value->value === '' ? [new Arg($value)] + : [new Arg($value), new Arg(new ConstFetch(new Name('false')), name: new Identifier('exact'))]; + } elseif ($type->isNonEmptyString()->yes()) { + $arguments[] = new Arg(new ConstFetch(new Name('false')), name: new Identifier('exact')); + } elseif ($value instanceof Variable && \is_string($value->name) && $type->isString()->yes()) { + $arguments[] = new Arg(new Identical($value, new String_('')), name: new Identifier('exact')); + } else { + $arguments = [$this->messageArguments($value)]; + } } } return new MethodCall($receiver, new Identifier($method), $arguments); @@ -305,31 +328,45 @@ private function messageArguments(Expr $message): Arg 'returnType' => new Identifier('array'), 'expr' => new Array_([ new ArrayItem($value), - new ArrayItem(new Identical($value, new String_(''))), + new ArrayItem(new Identical($value, new String_('')), new String_('exact')), ]), ]), [new Arg($message)]), unpack: true); } - private function temporaryName(Node $node): ?Variable + private function temporaryName(Node $node, Expression $statement): ?Variable { + # The method declaration's scope is outside the function, where every local is "maybe". + $scope = $statement->getAttribute(AttributeKey::SCOPE); + if ($scope instanceof Scope && $scope->canAnyVariableExist()) { + # After extract(), no local name can be proven fresh. + return null; + } + $finder = new NodeFinder(); + # A nested statement block does not contain variables read or captured elsewhere + # in its function. Reserve those names too, including conditionally defined locals. + foreach ($finder->findInstanceOf($this->getFile()->getNewStmts(), FunctionLike::class) as $function) { + if ($function === $node || $finder->findFirst($function, static fn(Node $child): bool => $child === $node) !== null) { + $node = $function; + break; + } + } $used = []; - foreach ((new NodeFinder())->findInstanceOf($node, Variable::class) as $variable) { + foreach ($finder->findInstanceOf($node, Variable::class) as $variable) { if (!\is_string($variable->name)) { return null; } $used[$variable->name] = true; } - $scope = $node->getAttribute(AttributeKey::SCOPE); $name = 'testoExpectedException'; - for ($suffix = 2; isset($used[$name]) || ($scope instanceof Scope && $scope->hasVariableType($name)->yes()); ++$suffix) { + for ($suffix = 2; isset($used[$name]) || ($scope instanceof Scope && !$scope->hasVariableType($name)->no()); ++$suffix) { $name = 'testoExpectedException' . $suffix; } return new Variable($name); } /** - * Whether the statement assigns a value that cannot throw to a plain variable: a scalar, a - * constant, or a concatenation or array built from them and from variables. + * Whether the statement is an assignment we can retain between expectation calls. + * Constants and concatenations can throw, so registration must stay before the assignment. */ private function isSafeAssignment(Node $stmt): bool { diff --git a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/dynamic_locals_unchanged.php.inc b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/dynamic_locals_unchanged.php.inc new file mode 100644 index 00000000..9ef01089 --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/dynamic_locals_unchanged.php.inc @@ -0,0 +1,12 @@ +expectException(\RuntimeException::class); + $message = 'boom'; + $this->expectExceptionMessage($message); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/fold_across_assignment.php.inc b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/fold_across_assignment.php.inc index f0dde218..b5a89915 100644 --- a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/fold_across_assignment.php.inc +++ b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/fold_across_assignment.php.inc @@ -20,7 +20,7 @@ class FoldAcrossAssignmentTest { $testoExpectedException = \Testo\Expect::exception(\InvalidArgumentException::class); $message = 'Type "float" is not supported. The valid types are: ' . self::TYPES . '.'; - $testoExpectedException->withMessage(...(static fn(string $message): array => [$message, $message === ''])($message)); + $testoExpectedException->withMessageContaining($message); new Compare(type: 'float'); } diff --git a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/independent_dynamic_constraints.php.inc b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/independent_dynamic_constraints.php.inc index 57f66193..3fa5a51d 100644 --- a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/independent_dynamic_constraints.php.inc +++ b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/independent_dynamic_constraints.php.inc @@ -24,11 +24,11 @@ class IndependentDynamicConstraints { public function repeatedMessageAndPattern(): void { - \Testo\Expect::exception(\Throwable::class)->withMessage('old', exact: false)->withMessageMatchingRegex('~two~')->withMessage(...(static fn(string $message): array => [$message, $message === ''])($this->message())); + \Testo\Expect::exception(\Throwable::class)->withMessage('old', exact: false)->withMessageMatchingRegex('~two~')->withMessage(...(static fn(string $message): array => [$message, 'exact' => $message === ''])($this->message())); } public function dynamicMessageAndPattern(): void { - \Testo\Expect::exception(\Throwable::class)->withMessage(...(static fn(string $message): array => [$message, $message === ''])($this->message()))->withMessageMatchingRegex('~two~'); + \Testo\Expect::exception(\Throwable::class)->withMessage(...(static fn(string $message): array => [$message, 'exact' => $message === ''])($this->message()))->withMessageMatchingRegex('~two~'); } } diff --git a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/message_expressions.php.inc b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/message_expressions.php.inc new file mode 100644 index 00000000..a15116cb --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/message_expressions.php.inc @@ -0,0 +1,78 @@ +expectException(\RuntimeException::class); + $this->expectExceptionMessage(self::MESSAGE); + } + + public function stringVariable(string $message): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage($message); + } + + public function concatenation(string $message): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('prefix ' . $message); + } + + public function formatted(string $message): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage(sprintf('prefix %s', $message)); + } + + public function effectful(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage($this->message()); + } + + private function message(): string + { + return ''; + } +} +----- +withMessageContaining(self::MESSAGE); + } + + public function stringVariable(string $message): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessage($message, exact: $message === ''); + } + + public function concatenation(string $message): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessageContaining('prefix ' . $message); + } + + public function formatted(string $message): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessageContaining(sprintf('prefix %s', $message)); + } + + public function effectful(): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessage(...(static fn(string $message): array => [$message, 'exact' => $message === ''])($this->message())); + } + + private function message(): string + { + return ''; + } +} diff --git a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/runtime_contracts.php.inc b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/runtime_contracts.php.inc index 444e34bc..1c83c53c 100644 --- a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/runtime_contracts.php.inc +++ b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/runtime_contracts.php.inc @@ -107,14 +107,6 @@ final class ExceptionRuntimeContracts throw new \RuntimeException('two'); } - public function longDynamicMessage(): void - { - $message = str_repeat('a', 100000); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage($message); - throw new \RuntimeException('prefix ' . $message . ' suffix'); - } - public function longRepeatedMessage(): void { $message = str_repeat('a', 100000); @@ -124,12 +116,12 @@ final class ExceptionRuntimeContracts throw new \RuntimeException('prefix ' . $message . ' suffix'); } - public function longMessageMustFail(): void + public function substringMismatchMustFail(): void { - $message = str_repeat('a', 100000); + $message = 'needle'; $this->expectException(\RuntimeException::class); $this->expectExceptionMessage($message); - throw new \RuntimeException(str_repeat('a', 99999)); + throw new \RuntimeException('needl'); } public function lastEmptyWins(): void @@ -166,6 +158,65 @@ final class ExceptionRuntimeContracts throw new \RuntimeException('two'); } + public function conditionalLocalSurvives(): void + { + if ($this->condition()) { + $testoExpectedException = 'source'; + } + if ($this->condition()) { + $this->expectException(\RuntimeException::class); + $message = 'two'; + $this->expectExceptionMessage($message); + } + \Testo\Assert::same($testoExpectedException, 'source'); + throw new \RuntimeException('two'); + } + + public function laterLocalSurvives(): void + { + if ($this->condition()) { + $this->expectException(\RuntimeException::class); + $message = 'two'; + $this->expectExceptionMessage($message); + } + $testoExpectedException ??= 'source'; + \Testo\Assert::same($testoExpectedException, 'source'); + throw new \RuntimeException('two'); + } + + public function assignmentCanThrow(): void + { + $part = new class { + public function __toString(): string + { + throw new \RuntimeException('assignment'); + } + }; + $this->expectException(\RuntimeException::class); + $message = 'prefix ' . $part; + $this->expectExceptionMessage($message); + } + + public function weakFalseMustFail(): void + { + $message = false; + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage($message); + throw new \RuntimeException('nonempty'); + } + + public function overwrittenMessageMustFail(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('expected'); + $this->expectExceptionMessage($this->throwOne()); + } + + private function condition(): bool + { + return true; + } + private function mark(string $value): string { $this->trace[] = $value; @@ -199,7 +250,7 @@ final class ExceptionRuntimeContracts public function lastMessageWins(): void { - \Testo\Expect::exception(\RuntimeException::class)->withMessage('one', exact: false)->withMessage('two', exact: false); + \Testo\Expect::exception(\RuntimeException::class)->withMessageContaining('two'); throw new \RuntimeException('two'); } @@ -211,27 +262,27 @@ final class ExceptionRuntimeContracts public function literalRegexCharacters(): void { - \Testo\Expect::exception(\RuntimeException::class)->withMessage('old', exact: false)->withMessage('~.[x]+$\\', exact: false); + \Testo\Expect::exception(\RuntimeException::class)->withMessageContaining('~.[x]+$\\'); throw new \RuntimeException('prefix ~.[x]+$\\ suffix'); } public function dynamicEmptyMustFail(): void { - \Testo\Expect::exception(\RuntimeException::class)->withMessage(...(static fn(string $message): array => [$message, $message === ''])($this->mark(''))); + \Testo\Expect::exception(\RuntimeException::class)->withMessage(...(static fn(string $message): array => [$message, 'exact' => $message === ''])($this->mark(''))); throw new \RuntimeException('nonempty'); } public function evaluationOrder(): void { $this->trace = []; - \Testo\Expect::exception($this->mark(\RuntimeException::class))->withMessage(...(static fn(string $message): array => [$message, $message === ''])($this->mark('one')))->withMessage(...(static fn(string $message): array => [$message, $message === ''])($this->mark('two')))->withCode(1)->withCode(2); + \Testo\Expect::exception($this->mark(\RuntimeException::class))->withMessage(...(static fn(string $message): array => [$message, 'exact' => $message === ''])($this->mark('one')))->withMessage(...(static fn(string $message): array => [$message, 'exact' => $message === ''])($this->mark('two')))->withCode(1)->withCode(2); \Testo\Assert::same($this->trace, [\RuntimeException::class, 'one', 'two']); throw new \RuntimeException('two', 2); } public function overwrittenArgumentThrows(): void { - \Testo\Expect::exception(\RuntimeException::class)->withMessage('one', exact: false)->withMessage(...(static fn(string $message): array => [$message, $message === ''])($this->throwOne())); + \Testo\Expect::exception(\RuntimeException::class)->withMessage('one', exact: false)->withMessage(...(static fn(string $message): array => [$message, 'exact' => $message === ''])($this->throwOne())); } public function independentPattern(): void @@ -251,10 +302,10 @@ final class ExceptionRuntimeContracts $class = \RuntimeException::class; $message = 'one'; $testoExpectedException = \Testo\Expect::exception($class); - $testoExpectedException->withMessage(...(static fn(string $message): array => [$message, $message === ''])($message)); + $testoExpectedException->withMessage($message, exact: false); $class = \LogicException::class; $message = 'two'; - $testoExpectedException->withMessage(...(static fn(string $message): array => [$message, $message === ''])($message)); + $testoExpectedException->withMessage($message, exact: false); throw new \RuntimeException('two'); } @@ -263,57 +314,106 @@ final class ExceptionRuntimeContracts $testoExpectedException = 'source'; $testoExpectedException2 = \Testo\Expect::exception(\RuntimeException::class); $message = 'two'; - $testoExpectedException2->withMessage(...(static fn(string $message): array => [$message, $message === ''])($message)); + $testoExpectedException2->withMessageContaining($message); \Testo\Assert::same($testoExpectedException, 'source'); throw new \RuntimeException('two'); } - public function longDynamicMessage(): void - { - $message = str_repeat('a', 100000); - \Testo\Expect::exception(\RuntimeException::class)->withMessage(...(static fn(string $message): array => [$message, $message === ''])($message)); - throw new \RuntimeException('prefix ' . $message . ' suffix'); - } - public function longRepeatedMessage(): void { $message = str_repeat('a', 100000); - \Testo\Expect::exception(\RuntimeException::class)->withMessage('old', exact: false)->withMessage(...(static fn(string $message): array => [$message, $message === ''])($message)); + \Testo\Expect::exception(\RuntimeException::class)->withMessage('old', exact: false)->withMessage($message, exact: false); throw new \RuntimeException('prefix ' . $message . ' suffix'); } - public function longMessageMustFail(): void + public function substringMismatchMustFail(): void { - $message = str_repeat('a', 100000); - \Testo\Expect::exception(\RuntimeException::class)->withMessage(...(static fn(string $message): array => [$message, $message === ''])($message)); - throw new \RuntimeException(str_repeat('a', 99999)); + $message = 'needle'; + \Testo\Expect::exception(\RuntimeException::class)->withMessageContaining($message); + throw new \RuntimeException('needl'); } public function lastEmptyWins(): void { - \Testo\Expect::exception(\RuntimeException::class)->withMessage('old', exact: false)->withMessage('', exact: true); + \Testo\Expect::exception(\RuntimeException::class)->withMessage(''); throw new \RuntimeException(''); } public function lastNonemptyWins(): void { - \Testo\Expect::exception(\RuntimeException::class)->withMessage('', exact: true)->withMessage('new', exact: false); + \Testo\Expect::exception(\RuntimeException::class)->withMessageContaining('new'); throw new \RuntimeException('prefix new suffix'); } public function dynamicMessageAndPattern(): void { $message = 'two'; - \Testo\Expect::exception(\RuntimeException::class)->withMessage(...(static fn(string $message): array => [$message, $message === ''])($message))->withMessageMatchingRegex('~^two$~'); + \Testo\Expect::exception(\RuntimeException::class)->withMessageContaining($message)->withMessageMatchingRegex('~^two$~'); throw new \RuntimeException('two'); } public function repeatedMessageAndPatternMustFail(): void { - \Testo\Expect::exception(\Throwable::class)->withMessage('old', exact: false)->withMessageMatchingRegex('~^three$~')->withMessage(...(static fn(string $message): array => [$message, $message === ''])($this->mark('two'))); + \Testo\Expect::exception(\Throwable::class)->withMessage('old', exact: false)->withMessageMatchingRegex('~^three$~')->withMessage(...(static fn(string $message): array => [$message, 'exact' => $message === ''])($this->mark('two'))); + throw new \RuntimeException('two'); + } + + public function conditionalLocalSurvives(): void + { + if ($this->condition()) { + $testoExpectedException = 'source'; + } + if ($this->condition()) { + $testoExpectedException2 = \Testo\Expect::exception(\RuntimeException::class); + $message = 'two'; + $testoExpectedException2->withMessageContaining($message); + } + \Testo\Assert::same($testoExpectedException, 'source'); throw new \RuntimeException('two'); } + public function laterLocalSurvives(): void + { + if ($this->condition()) { + $testoExpectedException2 = \Testo\Expect::exception(\RuntimeException::class); + $message = 'two'; + $testoExpectedException2->withMessageContaining($message); + } + $testoExpectedException ??= 'source'; + \Testo\Assert::same($testoExpectedException, 'source'); + throw new \RuntimeException('two'); + } + + public function assignmentCanThrow(): void + { + $part = new class { + public function __toString(): string + { + throw new \RuntimeException('assignment'); + } + }; + $testoExpectedException = \Testo\Expect::exception(\RuntimeException::class); + $message = 'prefix ' . $part; + $testoExpectedException->withMessageContaining($message); + } + + public function weakFalseMustFail(): void + { + $message = false; + \Testo\Expect::exception(\RuntimeException::class)->withMessage(...(static fn(string $message): array => [$message, 'exact' => $message === ''])($message)); + throw new \RuntimeException('nonempty'); + } + + public function overwrittenMessageMustFail(): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessage('expected', exact: false)->withMessage(...(static fn(string $message): array => [$message, 'exact' => $message === ''])($this->throwOne())); + } + + private function condition(): bool + { + return true; + } + private function mark(string $value): string { $this->trace[] = $value; diff --git a/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/strict_message_coercion.php.inc b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/strict_message_coercion.php.inc new file mode 100644 index 00000000..4bd4923c --- /dev/null +++ b/bridge/rector/src/PhpunitToTesto/ExpectExceptionToTestoRector/strict_message_coercion.php.inc @@ -0,0 +1,28 @@ +expectException(\TypeError::class); + $this->expectExceptionMessage($message); + } +} +----- +withMessage(...(static fn(string $message): array => [$message, 'exact' => $message === ''])($message)); + } +} diff --git a/bridge/rector/src/PhpunitToTesto/TODO.md b/bridge/rector/src/PhpunitToTesto/TODO.md index 71067b21..6bd52103 100644 --- a/bridge/rector/src/PhpunitToTesto/TODO.md +++ b/bridge/rector/src/PhpunitToTesto/TODO.md @@ -45,13 +45,21 @@ Mocks are out of this set: they convert through `phpunit-to-double` or `phpunit- rewriting is left manual. - **ExpectExceptionToTestoRector** (registered) β€” folds sibling class/message/regex/code expectations while preserving argument evaluation order and last-wins messages/codes. A single - nonempty literal uses `withMessageContaining()`, an empty literal uses exact `withMessage('')`, - and dynamic/repeated messages use replaceable `withMessage($message, exact: $message === '')` - without regex length limits. A typed closure evaluates each dynamic argument once. Named source - parameters are validated and emitted positionally. Across assignments a fresh local holds the + provably nonempty message uses `withMessageContaining()`, and an empty literal uses `withMessage('')`. + Adjacent overwritten message literals without comments are removed. Retained repeated nonempty + messages use `withMessage($message, exact: false)`; a string variable whose emptiness is unknown + uses `withMessage($message, exact: $message === '')`. Other expressions use + `withMessage(...(static fn(string $message): array => [$message, 'exact' => $message === ''])($expression))` + to evaluate and coerce the argument once before selecting the mode. The named `exact` key makes + older Assert versions fail explicitly instead of ignoring a positional flag. See the README's + generated-code compatibility requirements. Named source parameters are validated and emitted + positionally. Across assignments a fresh local holds the handler, keeping registration and argument snapshots at the original statements. Other statements end the run. Message and regex expectations retain independent fields, including dynamic and - repeated messages. Variable variables prevent introducing a handler local. + repeated messages. Handler names are reserved across the containing function, including conditional + and later locals. Variable variables or locals introduced by `extract()` prevent introducing a + handler local. Assignments can throw, + so handler registration remains at its original statement. A bare message/code with no preceding class expectation remains manual. - **GroupToTestoRector** (registered) β€” collapses every PHPUnit group source on a node β€” the `@group` docblock annotation(s) **and** the repeatable single-name `#[Group]` attribute(s) β€” into diff --git a/bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector.php b/bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector.php index fdb58531..4f3b6f27 100644 --- a/bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector.php +++ b/bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector.php @@ -115,16 +115,23 @@ public function refactor(Node $node): ?array $stmts = [$this->expectStmt('expectException', [$this->arg($headArg->value)])]; $emitted = []; + $hasMessage = false; foreach (\array_reverse($links) as $link) { $modifier = $this->getName($link->name); + if ($modifier === 'withMessage') { + if ($hasMessage) { + return null; + } + $hasMessage = true; + } $mapped = $modifier === null ? null : $this->mapModifier($modifier, $link->args); if ($mapped === null) { # Any unmapped modifier aborts the whole conversion β€” never half-convert. return null; } - # A repeated PHPUnit expectation overrides the earlier one, while Testo checks both. + # Independent Testo constraints must not collapse onto one replaceable PHPUnit field. \assert($mapped->expr instanceof MethodCall); $method = (string) $this->getName($mapped->expr->name); if (isset($emitted[$method])) { diff --git a/bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector/repeated_message_modes_unchanged.php.inc b/bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector/repeated_message_modes_unchanged.php.inc new file mode 100644 index 00000000..d90d3636 --- /dev/null +++ b/bridge/rector/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector/repeated_message_modes_unchanged.php.inc @@ -0,0 +1,23 @@ +withMessage('a')->withMessage('', exact: false); + throw new \RuntimeException('zzz'); + } + + public function exactMessageWins(): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessage('', exact: false)->withMessage('a'); + throw new \RuntimeException('a'); + } + + public function emptyModeChanges(): void + { + \Testo\Expect::exception(\RuntimeException::class)->withMessage('')->withMessage('', exact: false); + throw new \RuntimeException('zzz'); + } +} diff --git a/bridge/rector/src/TestoToPhpunit/TODO.md b/bridge/rector/src/TestoToPhpunit/TODO.md index 3ba84c07..99ccf079 100644 --- a/bridge/rector/src/TestoToPhpunit/TODO.md +++ b/bridge/rector/src/TestoToPhpunit/TODO.md @@ -47,6 +47,8 @@ directory but are **not** registered in `config/testo-to-phpunit.php`. A literal `withMessage('value', exact: false)` maps to `expectExceptionMessage`; an empty substring maps to the always-matching `expectExceptionMessageMatches('~~')`. Dynamic comparison modes or unpacked arguments stay manual so their empty-string semantics and evaluations are not lost. + Repeated `withMessage()` calls also stay manual, even when their modes map to different PHPUnit + methods: they replace one Testo field rather than establishing independent constraints. - **`ExpectExceptionAttributeToPhpUnitRector`** (registered) β€” converts the attribute form `#[\Testo\Assert\ExpectException($class)]` into a `$this->expectException($class);` statement prepended to the method body (PHPUnit dropped its `ExpectException` attribute, leaving only the diff --git a/bridge/rector/tests/Feature/ExceptionExpectationRuntimeTest.php b/bridge/rector/tests/Feature/ExceptionExpectationRuntimeTest.php new file mode 100644 index 00000000..16655fc6 --- /dev/null +++ b/bridge/rector/tests/Feature/ExceptionExpectationRuntimeTest.php @@ -0,0 +1,111 @@ +convert($input); + + $this->assertResults($converted, [ + 'emptyMustFail' => [Status::Failed, 1], + 'emptyPasses' => [Status::Passed, 1], + 'lastMessageWins' => [Status::Passed, 1], + 'namedArguments' => [Status::Passed, 1], + 'literalRegexCharacters' => [Status::Passed, 1], + 'dynamicEmptyMustFail' => [Status::Failed, 1], + 'evaluationOrder' => [Status::Passed, 2], + 'overwrittenArgumentThrows' => [Status::Passed, 1], + 'independentPattern' => [Status::Passed, 1], + 'independentPatternMustFail' => [Status::Failed, 1], + 'assignmentKeepsSnapshots' => [Status::Passed, 1], + 'temporaryDoesNotClobberSource' => [Status::Passed, 2], + 'longRepeatedMessage' => [Status::Passed, 1], + 'substringMismatchMustFail' => [Status::Failed, 1], + 'lastEmptyWins' => [Status::Passed, 1], + 'lastNonemptyWins' => [Status::Passed, 1], + 'dynamicMessageAndPattern' => [Status::Passed, 1], + 'repeatedMessageAndPatternMustFail' => [Status::Failed, 1], + 'conditionalLocalSurvives' => [Status::Passed, 2], + 'laterLocalSurvives' => [Status::Passed, 2], + 'assignmentCanThrow' => [Status::Passed, 1], + 'weakFalseMustFail' => [Status::Failed, 1], + 'overwrittenMessageMustFail' => [Status::Failed, 1], + ]); + } + + public function strictMessageCoercionStillThrows(): void + { + $fixture = \dirname(__DIR__, 2) . '/src/PhpunitToTesto/ExpectExceptionToTestoRector/strict_message_coercion.php.inc'; + [$input] = \explode("-----\n", \str_replace("\r\n", "\n", (string) \file_get_contents($fixture)), 2); + $runner = new RectorRunner(new RecordingMessenger(), [ExpectExceptionToTestoRector::class]); + + $this->assertResults($runner->convert($input), ['invalidStringStillThrows' => [Status::Passed, 1]]); + } + + public function repeatedMessageModesKeepTheirVerdictsInReverse(): void + { + $fixture = \dirname(__DIR__, 2) . '/src/TestoToPhpunit/ExpectExceptionToPhpUnitRector/repeated_message_modes_unchanged.php.inc'; + $runner = new RectorRunner(new RecordingMessenger(), [ExpectExceptionToPhpUnitRector::class]); + + $this->assertResults($runner->convert((string) \file_get_contents($fixture)), [ + 'emptySubstringWins' => [Status::Passed, 1], + 'exactMessageWins' => [Status::Passed, 1], + 'emptyModeChanges' => [Status::Passed, 1], + ]); + } + + /** + * @param array $expected + */ + private function assertResults(string $converted, array $expected): void + { + $file = \sys_get_temp_dir() . '/testo-converted-' . \bin2hex(\random_bytes(8)) . '.php'; + $sink = \fopen('php://memory', 'w+b'); + try { + \file_put_contents($file, $converted); + $app = Application::createFromConfig(new ApplicationConfig( + suites: [new SuiteConfig('Converted', location: new FinderConfig(include: [$file]))], + )); + $app->getContainer()->set(new ConsoleStreams($sink, $sink), ConsoleStreams::class); + $result = $app->run(); + } finally { + \fclose($sink); + @\unlink($file); + } + + $actual = []; + foreach ($result as $suite) { + foreach ($suite as $case) { + foreach ($case as $test) { + $actual[$test->info->name] = [$test->status, $test->summary->metric('assertions')]; + } + } + } + \ksort($actual); + \ksort($expected); + Assert::same($actual, $expected); + } +} diff --git a/bridge/rector/tests/Unit/ExceptionExpectationRuntimeTest.php b/bridge/rector/tests/Unit/ExceptionExpectationRuntimeTest.php deleted file mode 100644 index b275411f..00000000 --- a/bridge/rector/tests/Unit/ExceptionExpectationRuntimeTest.php +++ /dev/null @@ -1,69 +0,0 @@ -convert($input); - $file = \sys_get_temp_dir() . '/testo-converted-' . \bin2hex(\random_bytes(8)) . '.php'; - $sink = \fopen('php://memory', 'w+b'); - try { - \file_put_contents($file, $converted); - $app = Application::createFromConfig(new ApplicationConfig( - suites: [new SuiteConfig('Converted', location: new FinderConfig(include: [$file]))], - )); - $app->getContainer()->set(new ConsoleStreams($sink, $sink), ConsoleStreams::class); - $result = $app->run(); - } finally { - \fclose($sink); - @\unlink($file); - } - - $totals = $result->summary; - Assert::same([$totals->total(), $totals->metric('assertions'), $totals->count(Status::Passed), $totals->count(Status::Failed)], [19, 21, 14, 5]); - $failures = []; - $assertions = []; - foreach ($result as $suite) { - foreach ($suite as $case) { - foreach ($case as $test) { - if ($test->status->isFailure()) { - $failures[] = $test->info->identity->fqn(); - } - $assertions[$test->info->name] = $test->summary->metric('assertions'); - } - } - } - Assert::same($failures, [ - 'ExceptionRuntimeContracts::emptyMustFail', - 'ExceptionRuntimeContracts::dynamicEmptyMustFail', - 'ExceptionRuntimeContracts::independentPatternMustFail', - 'ExceptionRuntimeContracts::longMessageMustFail', - 'ExceptionRuntimeContracts::repeatedMessageAndPatternMustFail', - ]); - Assert::same($assertions['evaluationOrder'], 2); - Assert::same($assertions['independentPattern'], 1); - Assert::same($assertions['emptyMustFail'], 1); - } -} 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 6db3112e..9584d25a 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 @@ -62,7 +62,7 @@ the assertion **argument order flips** (see the pitfalls), and discovery is attr | `$this->assertIsResource($x)` / `assertIsClosedResource($x)` | `Assert::true(\str_starts_with(\gettype($x), 'resource'))` / `Assert::same(\gettype($x), 'resource (closed)')` β€” PHPUnit counts a closed resource, `is_resource()` does not. | | `$this->assertJson($s)` | `Assert::json($s)` (no `$message` arg). The JSON equality assertions have no counterpart: PHPUnit compares canonical re-encoded JSON (`1` β‰  `1.0`, `{}` β‰  `[]`), which `Assert::equals()` over decoded values does not. | | `$this->expectException(X::class)` before Act | `Expect::exception(X::class)->withCode(...)` before Act. Method return type becomes `never`. | -| `$this->expectExceptionMessage('...')` | A single nonempty literal uses `->withMessageContaining('...')`; `''` uses exact `->withMessage('')`. Dynamic or repeated messages use replaceable `withMessage($message, exact: $message === '')`, evaluating each expression once. Substrings use `str_contains()` without regex length limits; independent message regexes stay separate. | +| `$this->expectExceptionMessage('...')` | A single provably nonempty message uses `->withMessageContaining(...)`; `''` uses `->withMessage('')`. Adjacent overwritten literals are removed. Retained nonempty messages use `withMessage($message, exact: false)`; a string variable uses `withMessage($message, exact: $message === '')`. Other expressions use `withMessage(...(static fn(string $message): array => [$message, 'exact' => $message === ''])($expression))` to evaluate/coerce once. This requires the Assert API with the `exact` parameter; older versions reject the named flag explicitly. Substrings have no regex length limit; independent regex constraints stay separate. | | `$this->expectExceptionMessageMatches('/.../')` | `->withMessageMatchingRegex('/.../')`. | | `$this->markTestSkipped('reason')` | `#[Skip('reason')]` from **`Testo\Skip`** when the call opens the test unconditionally β€” the test then never enters the pipeline (no hooks, no provider, no retries). A guarded call, one deeper in the body, or a non-literal message stays runtime: `throw new \Testo\Core\Exception\SkipTest('reason')` from the test body. | | `$this->markTestIncomplete('reason')` | No "incomplete" status. Port to `throw new SkipTest('TODO: reason')`, or leave the body empty β†’ `Status::Risky`. |