Skip to content
Merged
2 changes: 1 addition & 1 deletion bridge/rector/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"require": {
"php": ">=8.2",
"internal/path": "^1.2",
"rector/rector": "^2.0"
"rector/rector": "^2.6.4"
},
"require-dev": {
"testo/assert": "^0.1.13",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ public function runTest(TestInfo $info, callable $next): TestResult

$results = [];
$status = Status::Passed;
$error = null;

try {
// Resolved inside the try so a path-containment violation surfaces as a test error
Expand Down Expand Up @@ -96,15 +95,25 @@ public function runTest(TestInfo $info, callable $next): TestResult
}
}
} catch (\Throwable $e) {
$status = Status::Error;
$error = $e;
# The rule failed before a single fixture ran (path resolution or the Rector container
# build). Report it as one errored data set so every reporter surfaces it the way it does
# a failing fixture: the batch node itself is only opened and closed, never re-reported,
# so a failure left on it alone would vanish into a bare "error" count.
$num = \count($results);
$dsInfo = $info->with(identity: $info->identity->toDataSet(dataProvider: 0, dataSet: $num));
$this->eventDispatcher->dispatch(new TestDataSetStarting($dsInfo, 'setup', null, $num));
$result = new TestResult(info: $dsInfo, status: Status::Error, failure: $e);
$this->eventDispatcher->dispatch(new TestDataSetFinished($dsInfo, $result, 'setup', null, $num));
$results[] = $result;
$status = Status::Failed;
}

if ($results === []) {
# No fixture matched: a risky no-op carrying an explanatory reason.
$final = new TestResult(
info: $info,
status: $status->isFailure() ? $status : Status::Risky,
result: $error ?? new \RuntimeException('No fixtures were found for this rule.'),
status: Status::Risky,
result: new \RuntimeException('No fixtures were found for this rule.'),
);
} else {
$multiple = new MultipleResult($results);
Expand Down
6 changes: 2 additions & 4 deletions bridge/rector/src/Testing/Internal/RectorRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,8 @@ public function __construct(Messenger $messenger, array $rules)
}

# Mirror AbstractRectorTestCase: hand the freshly-registered rules to the traverser.
# `tagged()` returns a Traversable (Illuminate RewindableGenerator); avoid naming the
# php-scoper-prefixed class and just iterate it.
$tagged = $rectorConfig->tagged(RectorInterface::class);
$rectors = \is_iterable($tagged) ? \iterator_to_array($tagged, false) : [];
# `findByContract()` returns them as a plain, 0-indexed list.
$rectors = $rectorConfig->findByContract(RectorInterface::class);
$rectorConfig->make(RectorNodeTraverser::class)->refreshPhpRectors($rectors);

$this->fileProcessor = $rectorConfig->make(ApplicationFileProcessor::class);
Expand Down
18 changes: 18 additions & 0 deletions bridge/rector/tests/Unit/Fixture/EscapingFixturesRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Tests\Bridge\Rector\Unit\Fixture;

use Testo\Bridge\Rector\Testing\TestRectorFixtures;

/**
* A rule whose declared fixtures path climbs out of the project root. Resolving it trips the
* FixtureResolver containment guard, which is how {@see RectorFixtureInterceptorTest} exercises a
* setup failure that happens before any fixture runs.
*/
#[TestRectorFixtures('../../../../../../../../../../../../../../../nowhere')]
final class EscapingFixturesRule
{
public function fixture(): void {}
}
133 changes: 133 additions & 0 deletions bridge/rector/tests/Unit/RectorFixtureInterceptorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<?php

declare(strict_types=1);

namespace Tests\Bridge\Rector\Unit;

use Internal\Path;
use Psr\EventDispatcher\EventDispatcherInterface;
use Testo\Assert;
use Testo\Bridge\Rector\Testing\Internal\Middleware\RectorFixtureInterceptor;
use Testo\Codecov\Covers;
use Testo\Common\Messenger;
use Testo\Common\Messenger\Channel;
use Testo\Core\Context\CaseInfo;
use Testo\Core\Context\Identity\SuiteIdentity;
use Testo\Core\Context\Identity\TestIdentity;
use Testo\Core\Context\TestInfo;
use Testo\Core\Context\TestResult;
use Testo\Core\Definition\CaseDefinition;
use Testo\Core\Definition\TestDefinition;
use Testo\Core\Log\Level;
use Testo\Core\Log\MessageLog;
use Testo\Core\Value\Status;
use Testo\Data\MultipleResult;
use Testo\Event\Test\TestDataSetStarting;
use Testo\Test;
use Tests\Bridge\Rector\Unit\Fixture\EscapingFixturesRule;

#[Test]
#[Covers(RectorFixtureInterceptor::class)]
final class RectorFixtureInterceptorTest
{
/**
* A rule that fails before any fixture runs (here, its fixtures path escapes the project and
* trips the containment guard) must surface that failure as an errored data set, not swallow it:
* reporters only open and close the batch node, so an error left on the batch alone is invisible.
*/
public function aSetupFailureIsReportedAsAnErroredDataSet(): void
{
$dispatcher = self::createDispatcher();
$interceptor = new RectorFixtureInterceptor($dispatcher, self::createMessenger());
$info = self::infoFor(EscapingFixturesRule::class);
$next = static fn(TestInfo $i): TestResult => new TestResult(info: $i, status: Status::Passed);

$result = $interceptor->runTest($info, $next);

Assert::same($result->status, Status::Failed);

$multiple = $result->getAttribute(MultipleResult::class);
Assert::true($multiple instanceof MultipleResult);
Assert::count($multiple->results, 1);

$errored = $multiple->results[0];
Assert::same($errored->status, Status::Error);
Assert::instanceOf($errored->failure, \LogicException::class);

# The failure rode a data set node, which is what makes every reporter render it.
$started = \array_filter(
$dispatcher->dispatched,
static fn(object $e): bool => $e instanceof TestDataSetStarting,
);
Assert::count($started, 1);
}

private static function infoFor(string $ruleClass): TestInfo
{
$class = new \ReflectionClass($ruleClass);
$caseDefinition = new CaseDefinition(
name: $class->getShortName(),
type: 'rector-fixture',
file: Path::create((string) $class->getFileName()),
reflection: $class,
);
$caseInfo = new CaseInfo(suiteIdentity: new SuiteIdentity('Bridge/Rector'), definition: $caseDefinition);
$testDefinition = new TestDefinition(reflection: new \ReflectionMethod($ruleClass, 'fixture'));

return new TestInfo(name: 'fixture', caseInfo: $caseInfo, testDefinition: $testDefinition);
}

/**
* @return EventDispatcherInterface&object{dispatched: list<object>}
*/
private static function createDispatcher(): EventDispatcherInterface
{
return new class() implements EventDispatcherInterface {
/** @var list<object> */
public array $dispatched = [];

#[\Override]
public function dispatch(object $event): object
{
$this->dispatched[] = $event;
return $event;
}
};
}

/** A messenger that is never touched on the setup-failure path; any call is a test bug. */
private static function createMessenger(): Messenger
{
return new class() implements Messenger {
#[\Override]
public function log(string $channel, string $content, Level $level = Level::Info, array $context = []): void
{
throw new \LogicException('messenger must not be used on the setup-failure path');
}

#[\Override]
public function channel(string $name): Channel
{
throw new \LogicException('messenger must not be used on the setup-failure path');
}

#[\Override]
public function scope(\Closure $scope, ?TestIdentity $identity = null): mixed
{
throw new \LogicException('messenger must not be used on the setup-failure path');
}

#[\Override]
public function fork(\Closure $fork, bool $holdEvents = false): mixed
{
throw new \LogicException('messenger must not be used on the setup-failure path');
}

#[\Override]
public function getMessages(): MessageLog
{
return new MessageLog();
}
};
}
}
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
"llm/skills": "^1.3",
"php-vcr/php-vcr": "^1.6",
"roxblnfk/unpoly": "1.8.2",
"rector/rector": "^2.5.2",
"rector/rector": "^2.6.4",
"testo/bridge-infection": "^0.1.8",
"testo/bridge-mockery": "^0.1.2",
"testo/bridge-rector": "^0.2.4",
Expand Down
40 changes: 40 additions & 0 deletions plugin/assert/tests/Self/AssertContains.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace Tests\Assert\Self;

use Testo\Assert;
use Testo\Assert\State\Assertion\AssertionException;
use Testo\Codecov\Covers;
use Testo\Expect;
use Testo\Test;

/**
* @see Assert::contains()
*/
#[Test]
#[Covers(Assert::class, 'contains')]
final class AssertContains
{
public function found(): void
{
Assert::contains([1, 2, 3], 2);
Assert::contains(new \ArrayIterator([1, 2, 3]), 3);
}

public function missingFails(): never
{
Expect::exception(AssertionException::class)
->withMessageContaining('`3` not found');
Assert::contains([1, 2], 3);
}

public function looseMatchIsNotEnough(): never
{
// Search is strict (===), so a numeric string does not match an int.
Expect::exception(AssertionException::class)
->withMessageContaining('`"2"` not found');
Assert::contains([1, 2, 3], '2');
}
}
30 changes: 30 additions & 0 deletions plugin/assert/tests/Self/AssertFail.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace Tests\Assert\Self;

use Testo\Assert;
use Testo\Assert\State\Test\Fail;
use Testo\Codecov\Covers;
use Testo\Expect;
use Testo\Test;

/**
* @see Assert::fail()
*/
#[Test]
#[Covers(Assert::class, 'fail')]
final class AssertFail
{
/**
* Assert::fail() throws a {@see Fail} carrying the given message. Expecting that
* exception lets the test pass while still exercising the failure path.
*/
public function throwsFailWithMessage(): never
{
Expect::exception(Fail::class)
->withMessageContaining('deliberate failure');
Assert::fail('deliberate failure');
}
}
38 changes: 38 additions & 0 deletions plugin/assert/tests/Self/AssertFalse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace Tests\Assert\Self;

use Testo\Assert;
use Testo\Assert\State\Assertion\AssertionException;
use Testo\Codecov\Covers;
use Testo\Expect;
use Testo\Test;

/**
* @see Assert::false()
*/
#[Test]
#[Covers(Assert::class, 'false')]
final class AssertFalse
{
public function exactlyFalse(): void
{
Assert::false(false);
}

public function trueFails(): never
{
Expect::exception(AssertionException::class)
->withMessageContaining('expected `false`, got `true`');
Assert::false(true);
}

public function falsyButNotFalseFails(): never
{
Expect::exception(AssertionException::class)
->withMessageContaining('expected `false`, got `0`');
Assert::false(0);
}
}
Loading
Loading