Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bridge/rector/FEATURE_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Type>`→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; 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* |
Expand Down
14 changes: 14 additions & 0 deletions bridge/rector/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading