From 18c6ed370d73cd55a679c9ff9a63d6ba3ebeddca Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 02:34:23 +0000 Subject: [PATCH 01/17] Make parallel process ownership failure-safe Track the exact tokens whose setup callbacks are entered and use that bounded local ownership set for teardown. Preserve the protected setup-loop extension point while rebuilding fresh applications for cleanup and retaining setup or runner failures over later teardown errors. Add counterfactual coverage for partial setup, custom token loops, unattempted tokens, runner suppression, application freshness, ordering, resolver restoration, cleanup exhaustion, and both primary-error precedence rules. --- src/testing/src/Concerns/RunsInParallel.php | 85 +++- tests/Testing/ParallelRunnerTest.php | 471 +++++++++++++++++--- 2 files changed, 475 insertions(+), 81 deletions(-) diff --git a/src/testing/src/Concerns/RunsInParallel.php b/src/testing/src/Concerns/RunsInParallel.php index 6b3a0e8dc..722434d81 100644 --- a/src/testing/src/Concerns/RunsInParallel.php +++ b/src/testing/src/Concerns/RunsInParallel.php @@ -6,15 +6,19 @@ use Closure; use Hypervel\Contracts\Console\Kernel; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Foundation\Application; use Hypervel\Support\Collection; use Hypervel\Support\Facades\ParallelTesting; use Hypervel\Testing\ParallelConsoleOutput; use ParaTest\Options; +use ParaTest\RunnerInterface; +use ParaTest\WrapperRunner\WrapperRunner; use PHPUnit\TextUI\Configuration\PhpHandler; use RuntimeException; use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\OutputInterface; +use Throwable; trait RunsInParallel { @@ -33,15 +37,10 @@ trait RunsInParallel */ protected Options $options; - /** - * The output instance. - */ - protected OutputInterface $output; - /** * The original test runner. */ - protected \ParaTest\RunnerInterface $runner; + protected RunnerInterface $runner; /** * Create a new test runner instance. @@ -55,7 +54,7 @@ public function __construct(Options $options, OutputInterface $output) } $runnerResolver = static::$runnerResolver ?: function (Options $options, OutputInterface $output) { - return new \ParaTest\WrapperRunner\WrapperRunner($options, $output); + return new WrapperRunner($options, $output); }; $this->runner = $runnerResolver($options, $output); @@ -90,38 +89,76 @@ public function execute(): int { (new PhpHandler)->handle($this->options->configuration->php()); - $this->forEachProcess(function () { - ParallelTesting::callSetUpProcessCallbacks(); - }); + $attemptedTokens = []; + $exception = null; + $exitCode = RunnerInterface::EXCEPTION_EXIT; try { - $exitCode = $this->runner->run(); - } finally { - $this->forEachProcess(function () { - ParallelTesting::callTearDownProcessCallbacks(); + $this->forEachProcess(function () use (&$attemptedTokens): void { + $attemptedTokens[] = (string) ParallelTesting::token(); + ParallelTesting::callSetUpProcessCallbacks(); }); + + $exitCode = $this->runner->run(); + } catch (Throwable $throwable) { + $exception = $throwable; + } + + // Re-running the overridable setup loop cannot guarantee the same owned token set. + foreach ($attemptedTokens as $token) { + try { + $this->forProcess($token, fn () => ParallelTesting::callTearDownProcessCallbacks()); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + } + + if ($exception !== null) { + throw $exception; } return $exitCode; } + // ParaTest 7 returns the final exit code from run() and exposes no getExitCode() method. + /** * Apply the given callback for each process. */ protected function forEachProcess(callable $callback): void { Collection::range(1, $this->options->processes)->each(function ($token) use ($callback): void { - $application = $this->createApplication(); + $this->forProcess((string) $token, $callback); + }); + } - try { - ParallelTesting::resolveTokenUsing(fn () => (string) $token); + /** + * Apply the given callback for one process. + */ + protected function forProcess(string $token, callable $callback): void + { + $application = $this->createApplication(); + $exception = null; - $callback($application); - } finally { - ParallelTesting::resolveTokenUsing(null); - $application->flush(); - } - }); + try { + ParallelTesting::resolveTokenUsing(fn () => $token); + + $callback($application); + } catch (Throwable $throwable) { + $exception = $throwable; + } + + ParallelTesting::resolveTokenUsing(null); + + try { + $application->flush(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + + if ($exception !== null) { + throw $exception; + } } /** @@ -129,7 +166,7 @@ protected function forEachProcess(callable $callback): void * * @throws RuntimeException */ - protected function createApplication(): \Hypervel\Contracts\Foundation\Application + protected function createApplication(): ApplicationContract { $applicationResolver = static::$applicationResolver ?: function () { $path = Application::inferBasePath() . '/bootstrap/app.php'; diff --git a/tests/Testing/ParallelRunnerTest.php b/tests/Testing/ParallelRunnerTest.php index 9c33e3cf7..91e86bda2 100644 --- a/tests/Testing/ParallelRunnerTest.php +++ b/tests/Testing/ParallelRunnerTest.php @@ -4,13 +4,16 @@ namespace Hypervel\Tests\Testing; +use Closure; use Hypervel\Container\Container; +use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Foundation\Application; use Hypervel\Support\Facades\ParallelTesting; use Hypervel\Testbench\TestCase; use Hypervel\Testing\ParallelRunner; use ParaTest\Options; +use ParaTest\RunnerInterface; use PHPUnit\Framework\Attributes\Test; use ReflectionClass; use ReflectionMethod; @@ -18,24 +21,61 @@ use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\InputDefinition; use Symfony\Component\Console\Output\BufferedOutput; +use Throwable; class ParallelRunnerTest extends TestCase { - private mixed $originalAppBasePathEnvironment; + /** @var array */ + private array $originalEnvironment; - private mixed $originalAppBasePathServer; + /** @var array */ + private array $originalServer; + + /** @var array */ + private array $originalProcessEnvironment; + + private ContainerContract $originalContainer; protected function setUp(): void { parent::setUp(); - $this->originalAppBasePathEnvironment = $_ENV['APP_BASE_PATH'] ?? null; - $this->originalAppBasePathServer = $_SERVER['APP_BASE_PATH'] ?? null; + $this->originalEnvironment = $this->snapshotArrayValues($_ENV, [ + 'APP_BASE_PATH', + 'COLUMNS', + 'LINES', + 'TEST_TOKEN', + ]); + $this->originalServer = $this->snapshotArrayValues($_SERVER, [ + 'APP_BASE_PATH', + 'HYPERVEL_PARALLEL_TESTING', + 'TEST_TOKEN', + ]); + $this->originalProcessEnvironment = [ + 'COLUMNS' => getenv('COLUMNS'), + 'LINES' => getenv('LINES'), + ]; + $this->originalContainer = Container::getInstance(); } protected function tearDown(): void { - $this->restoreAppBasePath(); + ParallelRunner::resolveApplicationUsing(null); + ParallelRunner::resolveRunnerUsing(null); + ParallelTesting::resolveTokenUsing(null); + + $this->restoreArrayValues($_ENV, $this->originalEnvironment); + $this->restoreArrayValues($_SERVER, $this->originalServer); + + foreach ($this->originalProcessEnvironment as $key => $value) { + if ($value === false) { + putenv($key); + } else { + putenv("{$key}={$value}"); + } + } + + Container::setInstance($this->originalContainer); parent::tearDown(); } @@ -49,14 +89,10 @@ public function itCreatesTheApplicationFromTheInferredBasePath(): void $runner = (new ReflectionClass(ParallelRunner::class))->newInstanceWithoutConstructor(); $method = new ReflectionMethod(ParallelRunner::class, 'createApplication'); - try { - /** @var ApplicationContract $createdApplication */ - $createdApplication = $method->invoke($runner); + /** @var ApplicationContract $createdApplication */ + $createdApplication = $method->invoke($runner); - $this->assertSame($this->app->basePath(), $createdApplication->basePath()); - } finally { - Container::setInstance($this->app); - } + $this->assertSame($this->app->basePath(), $createdApplication->basePath()); } #[Test] @@ -69,14 +105,9 @@ public function itResolvesProcessTokensAsStrings(): void $runner = new ParallelRunner($this->optionsWithProcesses(2), new BufferedOutput); $method = new ReflectionMethod(ParallelRunner::class, 'forEachProcess'); - try { - $method->invoke($runner, function () use (&$tokens): void { - $tokens[] = ParallelTesting::token(); - }); - } finally { - ParallelRunner::resolveApplicationUsing(null); - ParallelTesting::resolveTokenUsing(null); - } + $method->invoke($runner, function () use (&$tokens): void { + $tokens[] = ParallelTesting::token(); + }); $this->assertSame(['1', '2'], $tokens); } @@ -89,34 +120,24 @@ public function itRestoresTheAmbientTokenResolverAfterEachProcess(): void new ParallelRunnerFlushTrackingApplication($this->app->basePath()), new ParallelRunnerFlushTrackingApplication($this->app->basePath()), ]; - $previousServerToken = is_string($_SERVER['TEST_TOKEN'] ?? null) ? $_SERVER['TEST_TOKEN'] : null; - $_SERVER['TEST_TOKEN'] = 'ambient'; ParallelRunner::resolveApplicationUsing(static fn () => array_shift($applications)); $runner = new ParallelRunner($this->optionsWithProcesses(2), new BufferedOutput); $method = new ReflectionMethod(ParallelRunner::class, 'forEachProcess'); - try { - $method->invoke($runner, function () use (&$tokens): void { - $tokens[] = ParallelTesting::token(); - }); + $method->invoke($runner, function () use (&$tokens): void { + $tokens[] = ParallelTesting::token(); + }); - $this->assertSame(['1', '2'], $tokens); - $this->assertSame('ambient', ParallelTesting::token()); - } finally { - ParallelRunner::resolveApplicationUsing(null); - ParallelTesting::resolveTokenUsing(null); - $this->restoreServerTestToken($previousServerToken); - } + $this->assertSame(['1', '2'], $tokens); + $this->assertSame('ambient', ParallelTesting::token()); } #[Test] public function itClearsTheTokenResolverAndFlushesTheApplicationWhenAProcessCallbackFails(): void { $application = new ParallelRunnerFlushTrackingApplication($this->app->basePath()); - $previousServerToken = is_string($_SERVER['TEST_TOKEN'] ?? null) ? $_SERVER['TEST_TOKEN'] : null; - $_SERVER['TEST_TOKEN'] = 'ambient'; ParallelRunner::resolveApplicationUsing(static fn () => $application); @@ -133,29 +154,226 @@ public function itClearsTheTokenResolverAndFlushesTheApplicationWhenAProcessCall $this->assertSame('process callback failed', $exception->getMessage()); $this->assertTrue($application->flushed); $this->assertSame('ambient', ParallelTesting::token()); - } finally { - ParallelRunner::resolveApplicationUsing(null); - ParallelTesting::resolveTokenUsing(null); - $this->restoreServerTestToken($previousServerToken); } } - /** - * Restore the APP_BASE_PATH values. - */ - protected function restoreAppBasePath(): void + #[Test] + public function itRunsSetupRunnerAndTeardownWithFreshApplicationsInTokenOrder(): void { - if ($this->originalAppBasePathEnvironment === null) { - unset($_ENV['APP_BASE_PATH']); - } else { - $_ENV['APP_BASE_PATH'] = $this->originalAppBasePathEnvironment; + $events = []; + $runner = new ParallelRunnerStub(1, onRun: function () use (&$events): void { + $events[] = 'runner'; + }); + $createdApplications = $this->trackingApplications(4); + + ParallelTesting::setUpProcess(function (string $token) use (&$events): void { + $events[] = "setup:{$token}"; + }); + ParallelTesting::tearDownProcess(function (string $token) use (&$events): void { + $events[] = "teardown:{$token}"; + }); + + $this->resolveRunnerUsing($runner); + $this->resolveApplicationsUsing($createdApplications); + + $exitCode = (new ParallelRunner($this->optionsWithProcesses(2), new BufferedOutput))->execute(); + + $this->assertSame(1, $exitCode); + $this->assertSame( + ['setup:1', 'setup:2', 'runner', 'teardown:1', 'teardown:2'], + $events, + ); + $this->assertSame(1, $runner->runCount); + $this->assertCount(4, array_unique(array_map(spl_object_id(...), $createdApplications))); + $this->assertContainsOnlyInstancesOf(ParallelRunnerFlushTrackingApplication::class, $createdApplications); + $this->assertSame([true, true, true, true], array_map( + static fn (ParallelRunnerFlushTrackingApplication $application): bool => $application->flushed, + $createdApplications, + )); + } + + #[Test] + public function itTearsDownEverySetupEnteredTokenAfterSetupFails(): void + { + $events = []; + $runner = new ParallelRunnerStub; + $createdApplications = $this->trackingApplications(4); + + ParallelTesting::setUpProcess(function (string $token) use (&$events): void { + $events[] = "setup:{$token}"; + + if ($token === '2') { + throw new RuntimeException('setup failed'); + } + }); + ParallelTesting::tearDownProcess(function (string $token) use (&$events): void { + $events[] = "teardown:{$token}"; + }); + + $this->resolveRunnerUsing($runner); + $this->resolveApplicationsUsing($createdApplications); + + try { + (new ParallelRunner($this->optionsWithProcesses(3), new BufferedOutput))->execute(); + $this->fail('The setup exception was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame('setup failed', $exception->getMessage()); } - if ($this->originalAppBasePathServer === null) { - unset($_SERVER['APP_BASE_PATH']); - } else { - $_SERVER['APP_BASE_PATH'] = $this->originalAppBasePathServer; + $this->assertSame(['setup:1', 'setup:2', 'teardown:1', 'teardown:2'], $events); + $this->assertSame(0, $runner->runCount); + $this->assertSame([true, true, true, true], array_map( + static fn (ParallelRunnerFlushTrackingApplication $application): bool => $application->flushed, + $createdApplications, + )); + } + + #[Test] + public function itDoesNotTearDownATokenWhoseApplicationCouldNotBeCreated(): void + { + $events = []; + $runner = new ParallelRunnerStub; + $setupApplication = new ParallelRunnerFlushTrackingApplication($this->app->basePath()); + $teardownApplication = new ParallelRunnerFlushTrackingApplication($this->app->basePath()); + + ParallelTesting::setUpProcess(function (string $token) use (&$events): void { + $events[] = "setup:{$token}"; + }); + ParallelTesting::tearDownProcess(function (string $token) use (&$events): void { + $events[] = "teardown:{$token}"; + }); + + $this->resolveRunnerUsing($runner); + $this->resolveApplicationsUsing([ + $setupApplication, + new RuntimeException('application failed'), + $teardownApplication, + ]); + + try { + (new ParallelRunner($this->optionsWithProcesses(3), new BufferedOutput))->execute(); + $this->fail('The application exception was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame('application failed', $exception->getMessage()); } + + $this->assertSame(['setup:1', 'teardown:1'], $events); + $this->assertSame(0, $runner->runCount); + $this->assertTrue($setupApplication->flushed); + $this->assertTrue($teardownApplication->flushed); + } + + #[Test] + public function itPreservesTheRunnerFailureWhileExhaustingTeardown(): void + { + $teardownTokens = []; + $runner = new ParallelRunnerStub(exception: new RuntimeException('runner failed')); + $createdApplications = $this->trackingApplications(4); + + ParallelTesting::tearDownProcess(function (string $token) use (&$teardownTokens): never { + $teardownTokens[] = $token; + + throw new RuntimeException("teardown {$token} failed"); + }); + + $this->resolveRunnerUsing($runner); + $this->resolveApplicationsUsing($createdApplications); + + try { + (new ParallelRunner($this->optionsWithProcesses(2), new BufferedOutput))->execute(); + $this->fail('The runner exception was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame('runner failed', $exception->getMessage()); + } + + $this->assertSame(['1', '2'], $teardownTokens); + $this->assertSame(1, $runner->runCount); + $this->assertSame([true, true, true, true], array_map( + static fn (ParallelRunnerFlushTrackingApplication $application): bool => $application->flushed, + $createdApplications, + )); + } + + #[Test] + public function itThrowsTheFirstTeardownFailureAfterASuccessfulRun(): void + { + $teardownTokens = []; + $runner = new ParallelRunnerStub; + + ParallelTesting::tearDownProcess(function (string $token) use (&$teardownTokens): never { + $teardownTokens[] = $token; + + throw new RuntimeException("teardown {$token} failed"); + }); + + $this->resolveRunnerUsing($runner); + $this->resolveApplicationsUsing($this->trackingApplications(4)); + + try { + (new ParallelRunner($this->optionsWithProcesses(2), new BufferedOutput))->execute(); + $this->fail('The teardown exception was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame('teardown 1 failed', $exception->getMessage()); + } + + $this->assertSame(['1', '2'], $teardownTokens); + $this->assertSame(1, $runner->runCount); + } + + #[Test] + public function itPreservesTheCallbackFailureWhenApplicationFlushAlsoFails(): void + { + $application = new ParallelRunnerFlushTrackingApplication( + $this->app->basePath(), + new RuntimeException('flush failed'), + ); + + $_SERVER['TEST_TOKEN'] = 'ambient'; + ParallelRunner::resolveApplicationUsing(static fn () => $application); + + $runner = new ParallelRunner($this->optionsWithProcesses(1), new BufferedOutput); + $method = new ReflectionMethod(ParallelRunner::class, 'forEachProcess'); + + try { + $method->invoke($runner, static function (): never { + throw new RuntimeException('callback failed'); + }); + $this->fail('The callback exception was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame('callback failed', $exception->getMessage()); + } + + $this->assertTrue($application->flushed); + $this->assertSame('ambient', ParallelTesting::token()); + } + + #[Test] + public function itUsesAnOverriddenSetupLoopButOwnsTeardownByAttemptedToken(): void + { + $setupTokens = []; + $teardownTokens = []; + $runner = new ParallelRunnerStub; + + ParallelTesting::setUpProcess(function (string $token) use (&$setupTokens): void { + $setupTokens[] = $token; + }); + ParallelTesting::tearDownProcess(function (string $token) use (&$teardownTokens): void { + $teardownTokens[] = $token; + }); + + $this->resolveRunnerUsing($runner); + $this->resolveApplicationsUsing($this->trackingApplications(4)); + + $parallelRunner = new ParallelRunnerWithCustomProcesses( + $this->optionsWithProcesses(5), + new BufferedOutput, + ['7', '3'], + ); + + $this->assertSame(0, $parallelRunner->execute()); + $this->assertSame(['7', '3'], $setupTokens); + $this->assertSame(['7', '3'], $teardownTokens); + $this->assertSame(1, $parallelRunner->loopCalls); } /** @@ -178,14 +396,79 @@ protected function optionsWithProcesses(int $processes): Options } /** - * Restore the TEST_TOKEN server value. + * Configure the application resolver with the given sequence. + * + * @param list $applications + */ + protected function resolveApplicationsUsing(array $applications): void + { + ParallelRunner::resolveApplicationUsing(static function () use (&$applications): ApplicationContract { + $application = array_shift($applications); + + if ($application instanceof Throwable) { + throw $application; + } + + return $application; + }); + } + + /** + * Configure the runner resolver. + */ + protected function resolveRunnerUsing(ParallelRunnerStub $runner): void + { + ParallelRunner::resolveRunnerUsing(static fn () => $runner); + } + + /** + * Create flush-tracking applications. + * + * @return list + */ + protected function trackingApplications(int $count): array + { + $applications = []; + + for ($index = 0; $index < $count; ++$index) { + $applications[] = new ParallelRunnerFlushTrackingApplication($this->app->basePath()); + } + + return $applications; + } + + /** + * Snapshot selected array values with their presence. + * + * @param array $values + * @param list $keys + * @return array + */ + protected function snapshotArrayValues(array $values, array $keys): array + { + $snapshot = []; + + foreach ($keys as $key) { + $snapshot[$key] = [array_key_exists($key, $values), $values[$key] ?? null]; + } + + return $snapshot; + } + + /** + * Restore selected array values with their original presence. + * + * @param array $values + * @param array $snapshot */ - protected function restoreServerTestToken(?string $token): void + protected function restoreArrayValues(array &$values, array $snapshot): void { - if ($token === null) { - unset($_SERVER['TEST_TOKEN']); - } else { - $_SERVER['TEST_TOKEN'] = $token; + foreach ($snapshot as $key => [$existed, $value]) { + if ($existed) { + $values[$key] = $value; + } else { + unset($values[$key]); + } } } } @@ -194,6 +477,16 @@ class ParallelRunnerFlushTrackingApplication extends Application { public bool $flushed = false; + /** + * Create a flush-tracking application. + */ + public function __construct( + ?string $basePath = null, + private readonly ?Throwable $flushException = null, + ) { + parent::__construct($basePath); + } + /** * Flush the container of all bindings and resolved instances. */ @@ -201,6 +494,70 @@ public function flush(): void { $this->flushed = true; + if ($this->flushException !== null) { + throw $this->flushException; + } + parent::flush(); } } + +class ParallelRunnerStub implements RunnerInterface +{ + public int $runCount = 0; + + /** + * Create a runner stub. + */ + public function __construct( + private readonly int $exitCode = RunnerInterface::SUCCESS_EXIT, + private readonly ?Throwable $exception = null, + private readonly ?Closure $onRun = null, + ) { + } + + /** + * Run the test suite. + */ + public function run(): int + { + ++$this->runCount; + + if ($this->onRun !== null) { + ($this->onRun)(); + } + + if ($this->exception !== null) { + throw $this->exception; + } + + return $this->exitCode; + } +} + +class ParallelRunnerWithCustomProcesses extends ParallelRunner +{ + public int $loopCalls = 0; + + /** + * Create a runner with a custom process sequence. + * + * @param list $tokens + */ + public function __construct(Options $options, BufferedOutput $output, private readonly array $tokens) + { + parent::__construct($options, $output); + } + + /** + * Apply the given callback for each process. + */ + protected function forEachProcess(callable $callback): void + { + ++$this->loopCalls; + + foreach ($this->tokens as $token) { + $this->forProcess($token, $callback); + } + } +} From a29769085fd461dfe439eab0a678d01d60fe6da0 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 02:34:29 +0000 Subject: [PATCH 02/17] Exhaust parallel teardown callbacks Run every registered process and test-case teardown callback in registration order even when an earlier callback fails, then rethrow the first failure. Keep setup callbacks fail-fast and retain the existing parallel-mode boundary and callback parameters. Expand focused coverage for single and competing teardown failures, callback order, first-error precedence, and the unchanged setup behavior. --- src/testing/src/ParallelTesting.php | 42 ++++++--- tests/Testing/ParallelTestingTest.php | 124 ++++++++++++++++++++++---- 2 files changed, 139 insertions(+), 27 deletions(-) diff --git a/src/testing/src/ParallelTesting.php b/src/testing/src/ParallelTesting.php index ccc03d211..f83547f7a 100644 --- a/src/testing/src/ParallelTesting.php +++ b/src/testing/src/ParallelTesting.php @@ -7,6 +7,7 @@ use Closure; use Hypervel\Contracts\Container\Container; use Hypervel\Support\Str; +use Throwable; class ParallelTesting { @@ -199,11 +200,9 @@ public function callSetUpTestDatabaseCallbacks(string $database): void public function callTearDownProcessCallbacks(): void { $this->whenRunningInParallel(function () { - foreach ($this->tearDownProcessCallbacks as $callback) { - $this->container->call($callback, [ - 'token' => $this->token(), - ]); - } + $this->callTearDownCallbacks($this->tearDownProcessCallbacks, [ + 'token' => $this->token(), + ]); }); } @@ -213,15 +212,36 @@ public function callTearDownProcessCallbacks(): void public function callTearDownTestCaseCallbacks(mixed $testCase): void { $this->whenRunningInParallel(function () use ($testCase) { - foreach ($this->tearDownTestCaseCallbacks as $callback) { - $this->container->call($callback, [ - 'testCase' => $testCase, - 'token' => $this->token(), - ]); - } + $this->callTearDownCallbacks($this->tearDownTestCaseCallbacks, [ + 'testCase' => $testCase, + 'token' => $this->token(), + ]); }); } + /** + * Call every teardown callback and preserve the first failure. + * + * @param list $callbacks + * @param array $parameters + */ + private function callTearDownCallbacks(array $callbacks, array $parameters): void + { + $exception = null; + + foreach ($callbacks as $callback) { + try { + $this->container->call($callback, $parameters); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + } + + if ($exception !== null) { + throw $exception; + } + } + /** * Get a parallel testing option. */ diff --git a/tests/Testing/ParallelTestingTest.php b/tests/Testing/ParallelTestingTest.php index 24698f74a..165b5d063 100644 --- a/tests/Testing/ParallelTestingTest.php +++ b/tests/Testing/ParallelTestingTest.php @@ -8,6 +8,7 @@ use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; use PHPUnit\Framework\Attributes\DataProvider; +use RuntimeException; use stdClass; class ParallelTestingTest extends TestCase @@ -43,7 +44,7 @@ protected function tearDown(): void parent::tearDown(); } - public function testTokenReturnsFalseWhenNotRunningInParallel() + public function testTokenReturnsFalseWhenNotRunningInParallel(): void { $parallelTesting = new ParallelTesting(new Container); @@ -52,7 +53,7 @@ public function testTokenReturnsFalseWhenNotRunningInParallel() $this->assertFalse($parallelTesting->token()); } - public function testTokenReturnsValueFromResolver() + public function testTokenReturnsValueFromResolver(): void { $parallelTesting = new ParallelTesting(new Container); @@ -61,7 +62,7 @@ public function testTokenReturnsValueFromResolver() $this->assertSame('3', $parallelTesting->token()); } - public function testInParallelReturnsFalseWithoutToken() + public function testInParallelReturnsFalseWithoutToken(): void { $parallelTesting = new ParallelTesting(new Container); @@ -71,7 +72,7 @@ public function testInParallelReturnsFalseWithoutToken() $this->assertFalse($parallelTesting->inParallel()); } - public function testInParallelReturnsFalseWithoutServerVariable() + public function testInParallelReturnsFalseWithoutServerVariable(): void { $parallelTesting = new ParallelTesting(new Container); @@ -81,7 +82,7 @@ public function testInParallelReturnsFalseWithoutServerVariable() $this->assertFalse($parallelTesting->inParallel()); } - public function testInParallelReturnsTrueWithTokenAndServerVariable() + public function testInParallelReturnsTrueWithTokenAndServerVariable(): void { $parallelTesting = new ParallelTesting(new Container); @@ -91,7 +92,7 @@ public function testInParallelReturnsTrueWithTokenAndServerVariable() $this->assertTrue($parallelTesting->inParallel()); } - public function testOptionReturnsFalseByDefault() + public function testOptionReturnsFalseByDefault(): void { $parallelTesting = new ParallelTesting(new Container); @@ -99,7 +100,7 @@ public function testOptionReturnsFalseByDefault() $this->assertFalse($parallelTesting->option('without_databases')); } - public function testOptionUsesCustomResolver() + public function testOptionUsesCustomResolver(): void { $parallelTesting = new ParallelTesting(new Container); @@ -109,7 +110,7 @@ public function testOptionUsesCustomResolver() $this->assertFalse($parallelTesting->option('without_databases')); } - public function testOptionResolverCanBeReset() + public function testOptionResolverCanBeReset(): void { $parallelTesting = new ParallelTesting(new Container); @@ -120,7 +121,7 @@ public function testOptionResolverCanBeReset() $this->assertFalse($parallelTesting->option('anything')); } - public function testSetUpTestCaseCallbacksNotCalledWithoutParallelTesting() + public function testSetUpTestCaseCallbacksNotCalledWithoutParallelTesting(): void { $parallelTesting = new ParallelTesting(new Container); @@ -137,7 +138,7 @@ public function testSetUpTestCaseCallbacksNotCalledWithoutParallelTesting() $this->assertFalse($called); } - public function testSetUpTestCaseCallbacksCalledWithToken() + public function testSetUpTestCaseCallbacksCalledWithToken(): void { $parallelTesting = new ParallelTesting(new Container); @@ -157,7 +158,7 @@ public function testSetUpTestCaseCallbacksCalledWithToken() $this->assertSame($this, $receivedTestCase); } - public function testTearDownTestCaseCallbacksNotCalledWithoutParallelTesting() + public function testTearDownTestCaseCallbacksNotCalledWithoutParallelTesting(): void { $parallelTesting = new ParallelTesting(new Container); @@ -174,7 +175,7 @@ public function testTearDownTestCaseCallbacksNotCalledWithoutParallelTesting() $this->assertFalse($called); } - public function testTearDownTestCaseCallbacksCalledWithToken() + public function testTearDownTestCaseCallbacksCalledWithToken(): void { $parallelTesting = new ParallelTesting(new Container); @@ -194,7 +195,7 @@ public function testTearDownTestCaseCallbacksCalledWithToken() $this->assertSame($this, $receivedTestCase); } - public function testMultipleCallbacksAreCalledInOrder() + public function testMultipleCallbacksAreCalledInOrder(): void { $parallelTesting = new ParallelTesting(new Container); @@ -217,7 +218,7 @@ public function testMultipleCallbacksAreCalledInOrder() $this->assertSame(['first', 'second', 'third'], $order); } - public function testCallbacksReceiveCorrectTokenValue() + public function testCallbacksReceiveCorrectTokenValue(): void { $parallelTesting = new ParallelTesting(new Container); @@ -238,7 +239,7 @@ public function testCallbacksReceiveCorrectTokenValue() $this->assertSame(['5', '10'], $tokens); } - public function testTokenResolverCanBeReset() + public function testTokenResolverCanBeReset(): void { $parallelTesting = new ParallelTesting(new Container); @@ -253,8 +254,99 @@ public function testTokenResolverCanBeReset() $this->assertFalse($parallelTesting->inParallel()); } + public function testTearDownProcessCallbacksContinueAfterFailuresAndThrowTheFirstFailure(): void + { + $parallelTesting = new ParallelTesting(new Container); + $callbacks = []; + + $_SERVER['HYPERVEL_PARALLEL_TESTING'] = true; + $parallelTesting->resolveTokenUsing(fn () => '4'); + + $parallelTesting->tearDownProcess(function (string $token) use (&$callbacks): never { + $callbacks[] = "first:{$token}"; + + throw new RuntimeException('first failure'); + }); + $parallelTesting->tearDownProcess(function (string $token) use (&$callbacks): void { + $callbacks[] = "second:{$token}"; + }); + $parallelTesting->tearDownProcess(function (string $token) use (&$callbacks): never { + $callbacks[] = "third:{$token}"; + + throw new RuntimeException('third failure'); + }); + + try { + $parallelTesting->callTearDownProcessCallbacks(); + $this->fail('The teardown exception was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame('first failure', $exception->getMessage()); + } + + $this->assertSame(['first:4', 'second:4', 'third:4'], $callbacks); + } + + public function testTearDownTestCaseCallbacksContinueAfterFailuresAndThrowTheFirstFailure(): void + { + $parallelTesting = new ParallelTesting(new Container); + $callbacks = []; + + $_SERVER['HYPERVEL_PARALLEL_TESTING'] = true; + $parallelTesting->resolveTokenUsing(fn () => '6'); + + $parallelTesting->tearDownTestCase(function (string $token, mixed $testCase) use (&$callbacks): never { + $this->assertSame($this, $testCase); + $callbacks[] = "first:{$token}"; + + throw new RuntimeException('first failure'); + }); + $parallelTesting->tearDownTestCase(function (string $token, mixed $testCase) use (&$callbacks): void { + $this->assertSame($this, $testCase); + $callbacks[] = "second:{$token}"; + }); + + try { + $parallelTesting->callTearDownTestCaseCallbacks($this); + $this->fail('The teardown exception was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame('first failure', $exception->getMessage()); + } + + $this->assertSame(['first:6', 'second:6'], $callbacks); + } + + public function testSetUpCallbacksRemainFailFast(): void + { + $parallelTesting = new ParallelTesting(new Container); + $callbacks = []; + + $_SERVER['HYPERVEL_PARALLEL_TESTING'] = true; + $parallelTesting->resolveTokenUsing(fn () => '8'); + + $parallelTesting->setUpProcess(function () use (&$callbacks): void { + $callbacks[] = 'first'; + }); + $parallelTesting->setUpProcess(function () use (&$callbacks): never { + $callbacks[] = 'second'; + + throw new RuntimeException('setup failed'); + }); + $parallelTesting->setUpProcess(function () use (&$callbacks): void { + $callbacks[] = 'third'; + }); + + try { + $parallelTesting->callSetUpProcessCallbacks(); + $this->fail('The setup exception was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame('setup failed', $exception->getMessage()); + } + + $this->assertSame(['first', 'second'], $callbacks); + } + #[DataProvider('allCallbackTypes')] - public function testAllCallbackTypesFireWhenInParallel(string $callback, array $callerArgs) + public function testAllCallbackTypesFireWhenInParallel(string $callback, array $callerArgs): void { $parallelTesting = new ParallelTesting(new Container); $caller = 'call' . ucfirst($callback) . 'Callbacks'; From 281ed2a7bf990f5ca3537298711b86f899076f59 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 02:34:43 +0000 Subject: [PATCH 03/17] Make test command resources failure-safe Give artisan test one explicit ownership boundary spanning profile allocation, temporary PHPUnit configuration, process execution, reporting, and cleanup. Release every owned file and directory on all terminal paths while preserving the operation failure or first cleanup failure. Require complete configuration and profile writes instead of accepting failed or partial publication. Add deterministic coverage for allocation, signal, process, reporting, coverage, publication, and competing cleanup failures, including exact worker-clone file restoration. --- src/testing/src/Console/TestCommandBase.php | 87 ++-- .../Profile/ExecutionFinishedSubscriber.php | 6 +- tests/Testing/Console/TestCommandTest.php | 375 +++++++++++++++++- .../ExecutionFinishedSubscriberTest.php | 109 +++++ 4 files changed, 541 insertions(+), 36 deletions(-) create mode 100644 tests/Testing/Profile/ExecutionFinishedSubscriberTest.php diff --git a/src/testing/src/Console/TestCommandBase.php b/src/testing/src/Console/TestCommandBase.php index 7f9ffd091..9bd996b00 100644 --- a/src/testing/src/Console/TestCommandBase.php +++ b/src/testing/src/Console/TestCommandBase.php @@ -23,6 +23,7 @@ use Symfony\Component\Console\Input\InputDefinition; use Symfony\Component\Process\Exception\ProcessSignaledException; use Symfony\Component\Process\Process; +use Throwable; abstract class TestCommandBase extends Command { @@ -84,39 +85,37 @@ public function handle(): int $this->clearEnv(); $parallel = (bool) $this->option('parallel'); - - if ($this->option('profile')) { - $this->ensureProfileDirectoryExists($this->profileDirectory()); - } - - $process = (new Process( - command: array_merge( - $this->binary(), - $parallel ? $this->paratestArguments($options) : $this->phpunitArguments($options), - ), - env: $parallel ? $this->paratestEnvironmentVariables() : $this->phpunitEnvironmentVariables(), - ))->setTimeout(null); + $exception = null; + $exitCode = self::FAILURE; try { - $process->setTty(! $this->option('without-tty')); - } catch (RuntimeException) { - } + if ($this->option('profile')) { + $this->ensureProfileDirectoryExists($this->profileDirectory()); + } - $exitCode = self::FAILURE; + $process = (new Process( + command: array_merge( + $this->binary(), + $parallel ? $this->paratestArguments($options) : $this->phpunitArguments($options), + ), + env: $parallel ? $this->paratestEnvironmentVariables() : $this->phpunitEnvironmentVariables(), + ))->setTimeout(null); + + try { + $process->setTty(! $this->option('without-tty')); + } catch (RuntimeException) { + } - try { - $exitCode = $process->run(function (string $type, string $line): void { - $this->output->write($line); - }); - } catch (ProcessSignaledException $exception) { - if (extension_loaded('pcntl') && $exception->getSignal() !== SIGINT) { - throw $exception; + try { + $exitCode = $process->run(function (string $type, string $line): void { + $this->output->write($line); + }); + } catch (ProcessSignaledException $processSignaledException) { + if (extension_loaded('pcntl') && $processSignaledException->getSignal() !== SIGINT) { + throw $processSignaledException; + } } - } finally { - $this->cleanupTemporaryConfigurationFile(); - } - try { if ($this->option('profile')) { $this->reportProfile(); } @@ -141,9 +140,30 @@ public function handle(): int )); } } + } catch (Throwable $throwable) { + $exception = $throwable; } finally { - $this->coverage?->cleanup(); - $this->cleanupProfileDirectory(); + try { + $this->cleanupTemporaryConfigurationFile(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + + try { + $this->coverage?->cleanup(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + + try { + $this->cleanupProfileDirectory(); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + } + + if ($exception !== null) { + throw $exception; } return $exitCode; @@ -526,7 +546,14 @@ protected function profileConfigurationFile(string $file): string $this->temporaryConfigurationFile = dirname($file) . DIRECTORY_SEPARATOR . '.hypervel-phpunit-profile-' . getmypid() . '-' . bin2hex(random_bytes(6)) . '.xml'; - $document->save($this->temporaryConfigurationFile); + $written = @$document->save($this->temporaryConfigurationFile); + + if ($written === false) { + throw new RuntimeException(sprintf( + 'Unable to write temporary PHPUnit configuration [%s].', + $this->temporaryConfigurationFile, + )); + } return $this->temporaryConfigurationFile; } diff --git a/src/testing/src/Profile/ExecutionFinishedSubscriber.php b/src/testing/src/Profile/ExecutionFinishedSubscriber.php index a0b518144..a1cdf3216 100644 --- a/src/testing/src/Profile/ExecutionFinishedSubscriber.php +++ b/src/testing/src/Profile/ExecutionFinishedSubscriber.php @@ -36,7 +36,11 @@ public function notify(ExecutionFinished $event): void $token = $_SERVER['TEST_TOKEN'] ?? $_ENV['TEST_TOKEN'] ?? 'default'; $path = $this->directory . DIRECTORY_SEPARATOR . 'profile-' . $token . '-' . getmypid() . '.json'; + $encoded = json_encode($slowTests, JSON_THROW_ON_ERROR); + $written = @file_put_contents($path, $encoded); - file_put_contents($path, json_encode($slowTests, JSON_THROW_ON_ERROR)); + if ($written !== strlen($encoded)) { + throw new RuntimeException(sprintf('Unable to write test profile [%s].', $path)); + } } } diff --git a/tests/Testing/Console/TestCommandTest.php b/tests/Testing/Console/TestCommandTest.php index d74549e4e..7c204c972 100644 --- a/tests/Testing/Console/TestCommandTest.php +++ b/tests/Testing/Console/TestCommandTest.php @@ -8,18 +8,70 @@ use Hypervel\Support\Env; use Hypervel\Testbench\TestCase; use Hypervel\Testing\Console\TestCommand; +use Hypervel\Testing\Coverage; use Hypervel\Testing\ParallelRunner; use Hypervel\Testing\Profile\ProfileExtension; use Override; use PHPUnit\Framework\Attributes\Test; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; +use RuntimeException; use Symfony\Component\Console\Tester\CommandTester; +use Throwable; use function Hypervel\Testbench\package_path; class TestCommandTest extends TestCase { + /** @var array */ + private array $originalConfigurationFiles; + + /** @var array{bool, mixed} */ + private array $originalArguments; + + protected function setUp(): void + { + parent::setUp(); + + $this->originalConfigurationFiles = []; + + foreach (['phpunit.xml', 'custom-phpunit.xml'] as $file) { + $path = $this->app->basePath($file); + $this->originalConfigurationFiles[$path] = [ + is_file($path), + is_file($path) ? (string) file_get_contents($path) : null, + ]; + } + + $this->originalArguments = [ + array_key_exists('argv', $_SERVER), + $_SERVER['argv'] ?? null, + ]; + } + + protected function tearDown(): void + { + foreach (glob($this->app->basePath('.hypervel-phpunit-profile-*.xml')) ?: [] as $path) { + unlink($path); + } + + foreach ($this->originalConfigurationFiles as $path => [$existed, $contents]) { + if ($existed) { + file_put_contents($path, $contents); + } elseif (is_file($path)) { + unlink($path); + } + } + + if ($this->originalArguments[0]) { + $_SERVER['argv'] = $this->originalArguments[1]; + } else { + unset($_SERVER['argv']); + } + + parent::tearDown(); + } + #[Test] public function itInjectsTheProfileExtensionIntoATemporaryConfigurationFile(): void { @@ -54,7 +106,6 @@ public function itInjectsTheProfileExtensionIntoATemporaryConfigurationFile(): v public function itRunsProfiledTestsWithRelativeConfigurationPaths(): void { $basePath = $this->createProfileProject(); - $originalArguments = $_SERVER['argv'] ?? []; $_SERVER['argv'] = ['artisan', 'test', '--profile']; $command = new TestCommandHarness(['profile' => true, 'without-tty' => true], $basePath); @@ -69,7 +120,6 @@ public function itRunsProfiledTestsWithRelativeConfigurationPaths(): void $this->assertStringContainsString('Top 10 slowest tests', $display); $this->assertStringContainsString('ProfileExampleTest', $display); } finally { - $_SERVER['argv'] = $originalArguments; $this->removeDirectory($basePath); } } @@ -78,7 +128,6 @@ public function itRunsProfiledTestsWithRelativeConfigurationPaths(): void public function itShowsNativePhpunitOutputForSequentialTests(): void { $basePath = $this->createProfileProject(); - $originalArguments = $_SERVER['argv'] ?? []; $_SERVER['argv'] = ['artisan', 'test']; $command = new TestCommandHarness(['without-tty' => true], $basePath); @@ -93,7 +142,6 @@ public function itShowsNativePhpunitOutputForSequentialTests(): void $this->assertStringContainsString('OK (1 test', $display); $this->assertStringContainsString('1 assertion', $display); } finally { - $_SERVER['argv'] = $originalArguments; $this->removeDirectory($basePath); } } @@ -300,6 +348,173 @@ public function itFiltersSpaceSeparatedCommandOptionValuesFromForwardedArguments $this->assertNotContains('80', $arguments); } + #[Test] + public function itRejectsAnUnpublishedTemporaryConfigurationFile(): void + { + if (function_exists('posix_geteuid') && posix_geteuid() === 0) { + $this->markTestSkipped('Permission checks are unreliable when running as root.'); + } + + $basePath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'hypervel-profile-config-' + . getmypid() . '-' . bin2hex(random_bytes(6)); + + mkdir($basePath, 0777, true); + file_put_contents($basePath . DIRECTORY_SEPARATOR . 'phpunit.xml', <<<'XML' + + +XML); + chmod($basePath, 0555); + + $command = new TestCommandHarness(['profile' => true], $basePath); + $command->setHypervel($this->app); + + try { + $command->phpUnitConfigurationFilePublic(); + $this->fail('The temporary configuration write failure was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertStringStartsWith( + "Unable to write temporary PHPUnit configuration [{$basePath}/.hypervel-phpunit-profile-", + $exception->getMessage(), + ); + } finally { + chmod($basePath, 0777); + $this->removeDirectory($basePath); + } + } + + #[Test] + public function itCleansEveryOwnedResourceWhenArgumentConstructionFails(): void + { + $this->writePhpunitConfiguration(); + $_SERVER['argv'] = ['artisan', 'test', '--profile']; + + $command = new TestCommandFailureHarness(['profile' => true, 'without-tty' => true]); + $command->phpunitArgumentsException = new RuntimeException('arguments failed'); + $command->setHypervel($this->app); + + try { + (new CommandTester($command))->execute([]); + $this->fail('The argument construction exception was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame('arguments failed', $exception->getMessage()); + } + + $this->assertCommandResourcesWereRemoved($command); + $this->assertSame(['temporary configuration', 'profile directory'], $command->cleanupOrder); + } + + #[Test] + public function itCleansEveryOwnedResourceAfterANonInterruptSignal(): void + { + $this->writePhpunitConfiguration(); + $_SERVER['argv'] = ['artisan', 'test', '--profile']; + + $command = new TestCommandFailureHarness(['profile' => true, 'without-tty' => true]); + $command->processCode = 'posix_kill(getmypid(), SIGTERM);'; + $command->setHypervel($this->app); + + try { + (new CommandTester($command))->execute([]); + $this->fail('The process signal exception was not thrown.'); + } catch (Throwable $throwable) { + $this->assertSame('The process has been signaled with signal "15".', $throwable->getMessage()); + } + + $this->assertCommandResourcesWereRemoved($command); + $this->assertSame(['temporary configuration', 'profile directory'], $command->cleanupOrder); + } + + #[Test] + public function itCleansEveryOwnedResourceWhenProfileReportingFails(): void + { + $this->writePhpunitConfiguration(); + $_SERVER['argv'] = ['artisan', 'test', '--profile']; + + $command = new TestCommandFailureHarness(['profile' => true, 'without-tty' => true]); + $command->profileReportException = new RuntimeException('report failed'); + $command->setHypervel($this->app); + + try { + (new CommandTester($command))->execute([]); + $this->fail('The profile report exception was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame('report failed', $exception->getMessage()); + } + + $this->assertCommandResourcesWereRemoved($command); + $this->assertSame(['temporary configuration', 'profile directory'], $command->cleanupOrder); + } + + #[Test] + public function itPreservesTheOperationFailureWhileExhaustingCleanup(): void + { + $this->writePhpunitConfiguration(); + $_SERVER['argv'] = ['artisan', 'test', '--profile']; + + $command = new TestCommandFailureHarness(['profile' => true, 'without-tty' => true]); + $command->allocateCoverageInBinary = true; + $command->profileReportException = new RuntimeException('operation failed'); + $command->temporaryConfigurationCleanupException = new RuntimeException('temporary cleanup failed'); + $command->coverageCleanupException = new RuntimeException('coverage cleanup failed'); + $command->profileCleanupException = new RuntimeException('profile cleanup failed'); + $command->setHypervel($this->app); + + try { + (new CommandTester($command))->execute([]); + $this->fail('The operation exception was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame('operation failed', $exception->getMessage()); + } + + $this->assertCommandResourcesWereRemoved($command); + $this->assertSame( + ['temporary configuration', 'coverage', 'profile directory'], + $command->cleanupOrder, + ); + } + + #[Test] + public function itThrowsTheFirstCleanupFailureAndStillRunsLaterCleanup(): void + { + $this->writePhpunitConfiguration(); + $_SERVER['argv'] = ['artisan', 'test', '--profile']; + + $command = new TestCommandFailureHarness(['profile' => true, 'without-tty' => true]); + $command->allocateCoverageInBinary = true; + $command->temporaryConfigurationCleanupException = new RuntimeException('temporary cleanup failed'); + $command->coverageCleanupException = new RuntimeException('coverage cleanup failed'); + $command->profileCleanupException = new RuntimeException('profile cleanup failed'); + $command->setHypervel($this->app); + + try { + (new CommandTester($command))->execute([]); + $this->fail('The cleanup exception was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame('temporary cleanup failed', $exception->getMessage()); + } + + $this->assertCommandResourcesWereRemoved($command); + $this->assertSame( + ['temporary configuration', 'coverage', 'profile directory'], + $command->cleanupOrder, + ); + } + + /** + * Assert that every command-owned filesystem resource was removed. + */ + protected function assertCommandResourcesWereRemoved(TestCommandFailureHarness $command): void + { + $this->assertNotNull($command->createdTemporaryConfigurationFile); + $this->assertFileDoesNotExist($command->createdTemporaryConfigurationFile); + $this->assertNotNull($command->createdProfileDirectory); + $this->assertDirectoryDoesNotExist($command->createdProfileDirectory); + + if ($command->coverageReporter !== null) { + $this->assertFileDoesNotExist($command->coverageReporter->path()); + } + } + /** * Write a PHPUnit configuration file into the disposable testbench app. */ @@ -408,7 +623,7 @@ protected function removeDirectory(string $path): void } } -final class TestCommandHarness extends TestCommand +class TestCommandHarness extends TestCommand { /** * Create a new test command harness. @@ -535,3 +750,153 @@ public function clearEnvPublic(): void $this->clearEnv(); } } + +class TestCommandFailureHarness extends TestCommandHarness +{ + public bool $allocateCoverageInBinary = false; + + public string $processCode = 'exit(0);'; + + public ?Throwable $phpunitArgumentsException = null; + + public ?Throwable $profileReportException = null; + + public ?Throwable $temporaryConfigurationCleanupException = null; + + public ?Throwable $coverageCleanupException = null; + + public ?Throwable $profileCleanupException = null; + + public ?string $createdTemporaryConfigurationFile = null; + + public ?string $createdProfileDirectory = null; + + public ?TestCommandFailureCoverage $coverageReporter = null; + + /** @var list */ + public array $cleanupOrder = []; + + /** + * Get the PHP binary to execute. + * + * @return array + */ + #[Override] + protected function binary(): array + { + if ($this->allocateCoverageInBinary) { + $this->coverage(); + } + + return [PHP_BINARY, '-r', $this->processCode, '--']; + } + + /** + * Get the array of arguments for running PHPUnit. + * + * @param array $options + * @return array + */ + #[Override] + protected function phpunitArguments(array $options): array + { + $arguments = parent::phpunitArguments($options); + + if ($this->phpunitArgumentsException !== null) { + throw $this->phpunitArgumentsException; + } + + return $arguments; + } + + /** + * Add the profile extension to a temporary PHPUnit configuration file. + */ + #[Override] + protected function profileConfigurationFile(string $file): string + { + return $this->createdTemporaryConfigurationFile = parent::profileConfigurationFile($file); + } + + /** + * Get the profile directory. + */ + #[Override] + protected function profileDirectory(): string + { + return $this->createdProfileDirectory = parent::profileDirectory(); + } + + /** + * Get the coverage reporter. + */ + #[Override] + protected function coverage(): Coverage + { + return $this->coverage ??= $this->coverageReporter ??= new TestCommandFailureCoverage($this); + } + + /** + * Report the slowest tests. + */ + #[Override] + protected function reportProfile(): void + { + if ($this->profileReportException !== null) { + throw $this->profileReportException; + } + } + + /** + * Remove the temporary PHPUnit configuration file. + */ + #[Override] + protected function cleanupTemporaryConfigurationFile(): void + { + $this->cleanupOrder[] = 'temporary configuration'; + parent::cleanupTemporaryConfigurationFile(); + + if ($this->temporaryConfigurationCleanupException !== null) { + throw $this->temporaryConfigurationCleanupException; + } + } + + /** + * Remove profile data. + */ + #[Override] + protected function cleanupProfileDirectory(): void + { + $this->cleanupOrder[] = 'profile directory'; + parent::cleanupProfileDirectory(); + + if ($this->profileCleanupException !== null) { + throw $this->profileCleanupException; + } + } +} + +class TestCommandFailureCoverage extends Coverage +{ + /** + * Create a coverage cleanup harness. + */ + public function __construct(private readonly TestCommandFailureHarness $command) + { + parent::__construct(); + } + + /** + * Remove temporary coverage data. + */ + #[Override] + public function cleanup(): void + { + $this->command->cleanupOrder[] = 'coverage'; + parent::cleanup(); + + if ($this->command->coverageCleanupException !== null) { + throw $this->command->coverageCleanupException; + } + } +} diff --git a/tests/Testing/Profile/ExecutionFinishedSubscriberTest.php b/tests/Testing/Profile/ExecutionFinishedSubscriberTest.php new file mode 100644 index 000000000..b324ebf12 --- /dev/null +++ b/tests/Testing/Profile/ExecutionFinishedSubscriberTest.php @@ -0,0 +1,109 @@ +start('test-id', 1.0); + $tracker->stop('test-id', 'Example test', 2.0); + + $subscriber = new ExecutionFinishedSubscriber($tracker, 'profile-write://directory'); + $event = (new ReflectionClass(ExecutionFinished::class))->newInstanceWithoutConstructor(); + + stream_wrapper_register('profile-write', ProfileWriteStreamWrapper::class); + + try { + $subscriber->notify($event); + $this->fail('The incomplete profile write was not rejected.'); + } catch (RuntimeException $exception) { + $this->assertStringStartsWith( + 'Unable to write test profile [profile-write://directory/profile-', + $exception->getMessage(), + ); + } finally { + stream_wrapper_unregister('profile-write'); + } + } + + /** + * Provide incomplete write modes. + * + * @return array + */ + public static function failedWrites(): array + { + return [ + 'false' => ['false'], + 'short' => ['short'], + ]; + } +} + +class ProfileWriteStreamWrapper +{ + /** @var null|resource */ + public mixed $context = null; + + public static string $writeMode = 'false'; + + public static int $writeCount = 0; + + /** + * Open the stream. + */ + public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool + { + return true; + } + + /** + * Write bytes to the stream. + */ + public function stream_write(string $data): int|false + { + if (static::$writeMode === 'false' || static::$writeCount++ > 0) { + return false; + } + + return max(0, strlen($data) - 1); + } + + /** + * Return stream metadata. + * + * @return array{mode: int} + */ + public function stream_stat(): array + { + return ['mode' => 0100666]; + } + + /** + * Return URL metadata. + * + * @return array{mode: int} + */ + public function url_stat(string $path, int $flags): array + { + return ['mode' => 0040777]; + } +} From 581b101621d2a38de7ba3bff1b00e302913e7168 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 02:34:52 +0000 Subject: [PATCH 04/17] Correct pending command isolation and debugging Restore numeric-string forbidden-output keys before strict matcher calls and clear all shared expectation state plus the OutputStyle binding after every attempted command. Remove the behaviorally inert expectedTables state so one command cannot contaminate the next. Implement one-shot dd output through BufferedOutput, publish the real command exit code, and prevent destructor replay. Add sequential failure and reuse regressions plus a subprocess fixture proving captured output, exit reporting, stdout delivery, and exactly one execution, and document the public debugging API. --- src/boost/docs/console-tests.md | 7 + .../Testing/Concerns/InteractsWithConsole.php | 5 - src/testing/src/PendingCommand.php | 87 +++++--- tests/Console/ArtisanCommandTest.php | 211 +++++++++++++++--- .../Fixtures/PendingCommandDdFixture.php | 43 ++++ 5 files changed, 292 insertions(+), 61 deletions(-) create mode 100644 tests/Console/Fixtures/PendingCommandDdFixture.php diff --git a/src/boost/docs/console-tests.md b/src/boost/docs/console-tests.md index 91132527f..de21e3d9c 100644 --- a/src/boost/docs/console-tests.md +++ b/src/boost/docs/console-tests.md @@ -47,6 +47,13 @@ $this->artisan('example:failing-command')->assertFailed(); The `assertOk` method is also available as an alias of the `assertSuccessful` method. +While debugging a console command test, you may use the `dd` method to execute the command and +dump its exit code and captured output: + +```php +$this->artisan('users:all')->dd(); +``` + ## Input / Output Expectations diff --git a/src/foundation/src/Testing/Concerns/InteractsWithConsole.php b/src/foundation/src/Testing/Concerns/InteractsWithConsole.php index ea79b5d74..1c1ccb2c0 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithConsole.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithConsole.php @@ -39,11 +39,6 @@ trait InteractsWithConsole */ public array $unexpectedOutputSubstrings = []; - /** - * All of the expected output tables. - */ - public array $expectedTables = []; - /** * All of the expected questions. */ diff --git a/src/testing/src/PendingCommand.php b/src/testing/src/PendingCommand.php index 231fa5cc2..bdba4b910 100644 --- a/src/testing/src/PendingCommand.php +++ b/src/testing/src/PendingCommand.php @@ -348,39 +348,61 @@ public function run(): int $mock = $this->mockConsoleOutput(); try { - $exitCode = $this->app - ->make(KernelContract::class) - ->call($this->command, $this->parameters, $mock); - } catch (NoMatchingExpectationException $e) { - if ($e->getMethodName() === 'askQuestion') { - $this->test->fail('Unexpected question "' . $e->getActualArguments()[0]->getQuestion() . '" was asked.'); + try { + $exitCode = $this->app + ->make(KernelContract::class) + ->call($this->command, $this->parameters, $mock); + } catch (NoMatchingExpectationException $e) { + if ($e->getMethodName() === 'askQuestion') { + $this->test->fail('Unexpected question "' . $e->getActualArguments()[0]->getQuestion() . '" was asked.'); + } + + throw $e; + } catch (PromptValidationException) { + $exitCode = Command::FAILURE; } - throw $e; - } catch (PromptValidationException) { - $exitCode = Command::FAILURE; - } + if ($this->expectedExitCode !== null) { + $this->test->assertEquals( + $this->expectedExitCode, + $exitCode, + "Expected status code {$this->expectedExitCode} but received {$exitCode}." + ); + } elseif ($this->unexpectedExitCode !== null) { + $this->test->assertNotEquals( + $this->unexpectedExitCode, + $exitCode, + "Unexpected status code {$this->unexpectedExitCode} was received." + ); + } - if ($this->expectedExitCode !== null) { - $this->test->assertEquals( - $this->expectedExitCode, - $exitCode, - "Expected status code {$this->expectedExitCode} but received {$exitCode}." - ); - } elseif (! is_null($this->unexpectedExitCode)) { - $this->test->assertNotEquals( - $this->unexpectedExitCode, - $exitCode, - "Unexpected status code {$this->unexpectedExitCode} was received." - ); + $this->verifyExpectations(); + + return $exitCode; + } finally { + $this->flushExpectations(); + + $this->app->offsetUnset(OutputStyle::class); } + } - $this->verifyExpectations(); - $this->flushExpectations(); + /** + * Debug the command. + */ + public function dd(): never + { + $this->hasExecuted = true; - $this->app->offsetUnset(OutputStyle::class); + $output = new BufferedOutput; + $consoleOutput = new OutputStyle(new ArrayInput($this->parameters), $output); + $exitCode = $this->app + ->make(KernelContract::class) + ->call($this->command, $this->parameters, $consoleOutput); - return $exitCode; + dd([ + 'exitCode' => $exitCode, + 'output' => $output->fetch(), + ]); } /** @@ -412,11 +434,11 @@ protected function verifyExpectations(): void $this->test->fail('Output does not contain "' . array_first($this->test->expectedOutputSubstrings) . '".'); } - if ($output = array_search(true, $this->test->unexpectedOutput)) { + if (($output = array_search(true, $this->test->unexpectedOutput)) !== false) { $this->test->fail('Output "' . $output . '" was printed.'); } - if ($output = array_search(true, $this->test->unexpectedOutputSubstrings)) { + if (($output = array_search(true, $this->test->unexpectedOutputSubstrings)) !== false) { $this->test->fail('Output "' . $output . '" was printed.'); } } @@ -444,7 +466,7 @@ protected function mockConsoleOutput() : $argument->getAutocompleterValues(); } - return $argument->getQuestion() == $question[0]; + return $argument->getQuestion() === $question[0]; })) ->andReturnUsing(function () use ($question, $i) { unset($this->test->expectedQuestions[$i]); @@ -508,7 +530,10 @@ private function createABufferedOutputMock() }); } + // PHP converts canonical numeric-string array keys to integers, so restore the public string contract before matching. foreach ($this->test->unexpectedOutput as $output => $displayed) { + $output = (string) $output; + /** @var \Mockery\Expectation $expectation */ $expectation = $mock->shouldReceive('doWrite'); $expectation->atLeast() @@ -521,6 +546,8 @@ private function createABufferedOutputMock() } foreach ($this->test->unexpectedOutputSubstrings as $text => $displayed) { + $text = (string) $text; + /** @var \Mockery\Expectation $expectation */ $expectation = $mock->shouldReceive('doWrite'); $expectation->atLeast() @@ -539,11 +566,11 @@ private function createABufferedOutputMock() */ protected function flushExpectations(): void { + $this->test->expectsOutput = null; $this->test->expectedOutput = []; $this->test->expectedOutputSubstrings = []; $this->test->unexpectedOutput = []; $this->test->unexpectedOutputSubstrings = []; - $this->test->expectedTables = []; $this->test->expectedQuestions = []; $this->test->expectedChoices = []; } diff --git a/tests/Console/ArtisanCommandTest.php b/tests/Console/ArtisanCommandTest.php index a143d2e87..182332713 100644 --- a/tests/Console/ArtisanCommandTest.php +++ b/tests/Console/ArtisanCommandTest.php @@ -4,18 +4,24 @@ namespace Hypervel\Tests\Console; +use Hypervel\Console\OutputStyle; use Hypervel\Contracts\Console\Kernel; +use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Facades\Artisan; use Hypervel\Testbench\TestCase; +use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\Console\Fixtures\FakeCommandWithPromptValidation; use Mockery as m; use Mockery\Exception\InvalidCountException; use Mockery\Exception\InvalidOrderException; use PHPUnit\Framework\AssertionFailedError; +use RuntimeException; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Process\Process; class ArtisanCommandTest extends TestCase { - public function testConsoleCommandPasses() + public function testConsoleCommandPasses(): void { Artisan::command('exit', fn () => 0); @@ -23,7 +29,7 @@ public function testConsoleCommandPasses() ->assertOk(); } - public function testConsoleCommandFails() + public function testConsoleCommandFails(): void { Artisan::command('exit', fn () => 1); @@ -34,7 +40,7 @@ public function testConsoleCommandFails() ->assertOk(); } - public function testConsoleCommandPassesWithOutput() + public function testConsoleCommandPassesWithOutput(): void { $this->registerSurveyCommand(); @@ -46,7 +52,7 @@ public function testConsoleCommandPassesWithOutput() ->assertExitCode(0); } - public function testConsoleCommandPassesWithRepeatingOutput() + public function testConsoleCommandPassesWithRepeatingOutput(): void { $this->registerSlimCommand(); @@ -61,7 +67,7 @@ public function testConsoleCommandPassesWithRepeatingOutput() ->assertExitCode(0); } - public function testConsoleCommandFailsFromUnexpectedOutput() + public function testConsoleCommandFailsFromUnexpectedOutput(): void { $this->registerSurveyCommand(); @@ -75,7 +81,7 @@ public function testConsoleCommandFailsFromUnexpectedOutput() ->assertExitCode(0); } - public function testConsoleCommandFailsFromUnexpectedOutputSubstring() + public function testConsoleCommandFailsFromUnexpectedOutputSubstring(): void { $this->registerContainsCommand(); @@ -87,7 +93,7 @@ public function testConsoleCommandFailsFromUnexpectedOutputSubstring() ->assertExitCode(0); } - public function testConsoleCommandFailsFromMissingOutput() + public function testConsoleCommandFailsFromMissingOutput(): void { $this->registerSurveyCommand(); @@ -103,7 +109,7 @@ public function testConsoleCommandFailsFromMissingOutput() }); } - public function testConsoleCommandFailsFromExitCodeMismatch() + public function testConsoleCommandFailsFromExitCodeMismatch(): void { $this->registerSurveyCommand(); @@ -116,7 +122,7 @@ public function testConsoleCommandFailsFromExitCodeMismatch() ->assertExitCode(1); } - public function testConsoleCommandFailsFromUnOrderedOutput() + public function testConsoleCommandFailsFromUnOrderedOutput(): void { $this->registerSlimCommand(); @@ -134,7 +140,7 @@ public function testConsoleCommandFailsFromUnOrderedOutput() }); } - public function testConsoleCommandPassesIfTheOutputContains() + public function testConsoleCommandPassesIfTheOutputContains(): void { $this->registerContainsCommand(); @@ -143,7 +149,7 @@ public function testConsoleCommandPassesIfTheOutputContains() ->assertExitCode(0); } - public function testConsoleCommandPassesIfOutputsSomething() + public function testConsoleCommandPassesIfOutputsSomething(): void { $this->registerContainsCommand(); @@ -152,7 +158,7 @@ public function testConsoleCommandPassesIfOutputsSomething() ->assertExitCode(0); } - public function testConsoleCommandPassesIfoutputsIsSomethingAndIsTheExpectedOutput() + public function testConsoleCommandPassesIfoutputsIsSomethingAndIsTheExpectedOutput(): void { $this->registerContainsCommand(); @@ -162,7 +168,7 @@ public function testConsoleCommandPassesIfoutputsIsSomethingAndIsTheExpectedOutp ->assertExitCode(0); } - public function testConsoleCommandFailIfDoesntOutputSomething() + public function testConsoleCommandFailIfDoesntOutputSomething(): void { Artisan::command('exit', fn () => 0); @@ -172,10 +178,10 @@ public function testConsoleCommandFailIfDoesntOutputSomething() ->expectsOutput() ->assertExitCode(0); - m::close(); + $this->verifyMockeryExpectationsNow(); } - public function testConsoleCommandFailIfDoesntOutputSomethingAndIsNotTheExpectedOutput() + public function testConsoleCommandFailIfDoesntOutputSomethingAndIsNotTheExpectedOutput(): void { Artisan::command('exit', fn () => 0); @@ -189,7 +195,7 @@ public function testConsoleCommandFailIfDoesntOutputSomethingAndIsNotTheExpected }); } - public function testConsoleCommandPassesIfDoesNotOutputAnything() + public function testConsoleCommandPassesIfDoesNotOutputAnything(): void { Artisan::command('exit', fn () => 0); @@ -198,7 +204,7 @@ public function testConsoleCommandPassesIfDoesNotOutputAnything() ->assertExitCode(0); } - public function testConsoleCommandPassesIfDoesNotOutputAnythingAndIsNotTheExpectedOutput() + public function testConsoleCommandPassesIfDoesNotOutputAnythingAndIsNotTheExpectedOutput(): void { Artisan::command('exit', fn () => 0); @@ -208,7 +214,7 @@ public function testConsoleCommandPassesIfDoesNotOutputAnythingAndIsNotTheExpect ->assertExitCode(0); } - public function testConsoleCommandPassesIfExpectsOutputAndThereIsInteractions() + public function testConsoleCommandPassesIfExpectsOutputAndThereIsInteractions(): void { $this->registerInteractionsCommand(); @@ -220,7 +226,7 @@ public function testConsoleCommandPassesIfExpectsOutputAndThereIsInteractions() ->assertExitCode(0); } - public function testConsoleCommandFailsIfDoesntExpectOutputButThereIsInteractions() + public function testConsoleCommandFailsIfDoesntExpectOutputButThereIsInteractions(): void { $this->registerInteractionsCommand(); @@ -233,10 +239,10 @@ public function testConsoleCommandFailsIfDoesntExpectOutputButThereIsInteraction ->expectsConfirmation('Do you want to continue?', 'no') ->assertExitCode(0); - m::close(); + $this->verifyMockeryExpectationsNow(); } - public function testConsoleCommandFailsIfDoesntExpectOutputButOutputsSomething() + public function testConsoleCommandFailsIfDoesntExpectOutputButOutputsSomething(): void { $this->registerContainsCommand(); @@ -246,10 +252,10 @@ public function testConsoleCommandFailsIfDoesntExpectOutputButOutputsSomething() ->doesntExpectOutput() ->assertExitCode(0); - m::close(); + $this->verifyMockeryExpectationsNow(); } - public function testConsoleCommandFailsIfDoesntExpectOutputSomethingAndIsNotExpectOutput() + public function testConsoleCommandFailsIfDoesntExpectOutputSomethingAndIsNotExpectOutput(): void { $this->registerContainsCommand(); @@ -260,10 +266,10 @@ public function testConsoleCommandFailsIfDoesntExpectOutputSomethingAndIsNotExpe ->doesntExpectOutput('My name is Albert Chen') ->assertExitCode(0); - m::close(); + $this->verifyMockeryExpectationsNow(); } - public function testConsoleCommandFailsIfTheOutputDoesNotContain() + public function testConsoleCommandFailsIfTheOutputDoesNotContain(): void { $this->registerContainsCommand(); @@ -277,7 +283,7 @@ public function testConsoleCommandFailsIfTheOutputDoesNotContain() }); } - public function testPendingCommandCanBeRapped() + public function testPendingCommandCanBeRapped(): void { Artisan::command('new-england', function () { $this->line('The region of New England consists of the following states:'); @@ -324,6 +330,135 @@ public function testPromptValidationExceptionProducesFailureWithoutErrorOutput() ->assertFailed(); } + public function testForbiddenOutputNamedStringZeroIsReported(): void + { + Artisan::command('zero-output', function () { + $this->line('0'); + }); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Output "0" was printed.'); + + $this->artisan('zero-output')->doesntExpectOutput('0')->run(); + } + + public function testForbiddenOutputSubstringNamedStringZeroIsReported(): void + { + Artisan::command('zero-substring', function () { + $this->line('value 0'); + }); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Output "0" was printed.'); + + $this->artisan('zero-substring')->doesntExpectOutputToContain('0')->run(); + } + + public function testCommandFailureDoesNotLeakExpectationsOrOutputBinding(): void + { + Artisan::command('throwing-command', function () { + throw new RuntimeException('command failed'); + }); + Artisan::command('clean-command', function () { + $this->line('clean output'); + }); + + try { + $this->artisan('throwing-command')->doesntExpectOutput('clean output')->run(); + $this->fail('The command did not fail.'); + } catch (RuntimeException $exception) { + $this->assertSame('command failed', $exception->getMessage()); + } + + $this->assertConsoleExpectationsFlushed(); + $this->artisan('clean-command')->expectsOutput('clean output')->assertSuccessful(); + } + + public function testExitAssertionFailureDoesNotLeakExpectationsOrOutputBinding(): void + { + Artisan::command('failing-exit', fn () => Command::FAILURE); + Artisan::command('successful-exit', fn () => Command::SUCCESS); + + try { + $this->artisan('failing-exit')->doesntExpectOutput('never printed')->assertSuccessful()->run(); + $this->fail('The exit assertion did not fail.'); + } catch (AssertionFailedError $exception) { + $this->assertStringContainsString('Expected status code 0 but received 1.', $exception->getMessage()); + } + + $this->assertConsoleExpectationsFlushed(); + $this->artisan('successful-exit')->assertSuccessful(); + } + + public function testVerificationFailureDoesNotLeakExpectationsOrOutputBinding(): void + { + Artisan::command('missing-output', fn () => Command::SUCCESS); + Artisan::command('verified-output', function () { + $this->line('verified'); + }); + + try { + $this->artisan('missing-output')->expectsOutputToContain('missing')->run(); + $this->fail('The output assertion did not fail.'); + } catch (AssertionFailedError $exception) { + $this->assertStringContainsString('Output does not contain "missing".', $exception->getMessage()); + } + + $this->assertConsoleExpectationsFlushed(); + $this->artisan('verified-output')->expectsOutput('verified')->assertSuccessful(); + } + + public function testNoOutputExpectationDoesNotDisableMatchersOnTheNextCommand(): void + { + Artisan::command('silent-command', fn () => Command::SUCCESS); + Artisan::command('output-command', function () { + $this->line('expected output'); + }); + + $this->artisan('silent-command')->doesntExpectOutput()->assertSuccessful(); + $this->artisan('output-command')->expectsOutput('expected output')->assertSuccessful(); + } + + public function testOutputExpectationDoesNotRequireOutputFromTheNextCommand(): void + { + Artisan::command('output-command', function () { + $this->line('expected output'); + }); + Artisan::command('silent-command', fn () => Command::SUCCESS); + + $this->artisan('output-command')->expectsOutput()->assertSuccessful(); + $this->artisan('silent-command')->assertSuccessful(); + } + + public function testDdCapturesOutputAndExecutesTheCommandOnce(): void + { + $directory = ParallelTesting::tempDir('PendingCommandDdFixture'); + $filesystem = new Filesystem; + $filesystem->deleteDirectory($directory); + $filesystem->makeDirectory($directory); + $counter = $directory . '/executions.txt'; + $process = new Process( + command: [PHP_BINARY, 'tests/Console/Fixtures/PendingCommandDdFixture.php'], + cwd: dirname(__DIR__, 2), + env: [ + 'PENDING_COMMAND_DD_COUNTER' => $counter, + 'TESTBENCH_BASE_PATH' => BASE_PATH, + ], + timeout: 30, + ); + + try { + $process->run(); + + $this->assertSame(1, $process->getExitCode()); + $this->assertStringContainsString('fixture output', $process->getOutput()); + $this->assertStringContainsString('"exitCode" => 7', $process->getOutput()); + $this->assertSame('1', file_get_contents($counter)); + } finally { + $filesystem->deleteDirectory($directory); + } + } + protected function registerSurveyCommand(): void { Artisan::command('survey', function () { @@ -373,6 +508,30 @@ protected function registerSlimCommand(): void }); } + /** + * Assert that the console expectations have been flushed. + */ + protected function assertConsoleExpectationsFlushed(): void + { + $this->assertNull($this->expectsOutput); + $this->assertSame([], $this->expectedOutput); + $this->assertSame([], $this->expectedOutputSubstrings); + $this->assertSame([], $this->unexpectedOutput); + $this->assertSame([], $this->unexpectedOutputSubstrings); + $this->assertSame([], $this->expectedQuestions); + $this->assertSame([], $this->expectedChoices); + $this->assertFalse($this->app->bound(OutputStyle::class)); + } + + /** + * Verify the PendingCommand mock expectations immediately, so an unmet + * expectation throws here and is caught by the test's expectException(). + */ + protected function verifyMockeryExpectationsNow(): void + { + m::close(); + } + protected function ignoringMockOnceExceptions(callable $callback): void { try { diff --git a/tests/Console/Fixtures/PendingCommandDdFixture.php b/tests/Console/Fixtures/PendingCommandDdFixture.php new file mode 100644 index 000000000..9debb19a6 --- /dev/null +++ b/tests/Console/Fixtures/PendingCommandDdFixture.php @@ -0,0 +1,43 @@ +make(KernelContract::class)->bootstrap(); + +Artisan::command('pending-command-dd-fixture', function () use ($counter) { + $executions = is_file($counter) ? (int) file_get_contents($counter) : 0; + file_put_contents($counter, (string) ($executions + 1)); + + $this->line('fixture output'); + + return 7; +}); + +$test = new class('fixture') extends TestCase { + /** + * Provide a concrete test method for the fixture test case. + */ + public function fixture(): void + { + } +}; + +(new PendingCommand($test, $app, 'pending-command-dd-fixture', []))->dd(); From 412a0f1c2061e44cb195e73794e2d08439fe99fb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 02:34:59 +0000 Subject: [PATCH 05/17] Normalize rendered HTML assertions consistently Add the shared rendered-HTML constraint used by TestView and TestComponent while preserving the separate raw ordered assertion path. Retain valid string zero, normalize Unicode whitespace, fall back safely for malformed bytes, reject textless expectations, and emit correctly composed PHPUnit diagnostics. Restore the current fluent View and Component assertion surface and truthful list annotations. Revalidate both Mailable ordered text callers with counterfactual zero-value coverage and document the complete component testing API. --- src/boost/docs/views.md | 6 + src/mail/src/Mailable.php | 10 +- src/testing/src/Constraints/SeeInHtml.php | 132 +++++++++++++++++++ src/testing/src/Constraints/SeeInOrder.php | 4 +- src/testing/src/TestComponent.php | 87 ++++++++++--- src/testing/src/TestView.php | 86 +++++++++--- tests/Mail/MailMailableAssertionsTest.php | 21 +++ tests/Testing/SeeInHtmlTest.php | 144 +++++++++++++++++++++ tests/Testing/TestViewTest.php | 67 +++++++++- 9 files changed, 516 insertions(+), 41 deletions(-) create mode 100644 src/testing/src/Constraints/SeeInHtml.php create mode 100644 tests/Testing/SeeInHtmlTest.php diff --git a/src/boost/docs/views.md b/src/boost/docs/views.md index 9eeccb67c..816dc95d0 100644 --- a/src/boost/docs/views.md +++ b/src/boost/docs/views.md @@ -13,6 +13,7 @@ - [Sharing Data With All Views](#sharing-data-with-all-views) - [View Composers](#view-composers) - [View Creators](#view-creators) +- [Testing Views](#testing-views) - [Optimizing Views](#optimizing-views) @@ -348,6 +349,11 @@ use Hypervel\Support\Facades\View; View::creator('profile', ProfileCreator::class); ``` + +## Testing Views + +Hypervel's test helpers can render views, Blade strings, and components without making an HTTP request. The resulting `TestView` and `TestComponent` instances provide assertions for raw HTML, escaped content, visible text, ordering, and view data. See the [HTTP testing documentation](/docs/{{version}}/http-tests#testing-views) for examples and the complete assertion API. + ## Optimizing Views diff --git a/src/mail/src/Mailable.php b/src/mail/src/Mailable.php index f8910126c..6fe6b9f53 100644 --- a/src/mail/src/Mailable.php +++ b/src/mail/src/Mailable.php @@ -1229,6 +1229,8 @@ public function assertDontSeeInHtml(string $string, bool $escape = true): static /** * Assert that the given text strings are present in order in the HTML email body. + * + * @param list $strings */ public function assertSeeInOrderInHtml(array $strings, bool $escape = true): static { @@ -1248,7 +1250,7 @@ public function assertSeeInOrderInHtml(array $strings, bool $escape = true): sta */ public function assertSeeInText(string $string): static { - [$html, $text] = $this->renderForAssertions(); + [, $text] = $this->renderForAssertions(); PHPUnit::assertStringContainsString( $string, @@ -1264,7 +1266,7 @@ public function assertSeeInText(string $string): static */ public function assertDontSeeInText(string $string): static { - [$html, $text] = $this->renderForAssertions(); + [, $text] = $this->renderForAssertions(); PHPUnit::assertStringNotContainsString( $string, @@ -1277,10 +1279,12 @@ public function assertDontSeeInText(string $string): static /** * Assert that the given text strings are present in order in the plain-text email body. + * + * @param list $strings */ public function assertSeeInOrderInText(array $strings): static { - [$html, $text] = $this->renderForAssertions(); + [, $text] = $this->renderForAssertions(); PHPUnit::assertThat($strings, new SeeInOrder($text)); diff --git a/src/testing/src/Constraints/SeeInHtml.php b/src/testing/src/Constraints/SeeInHtml.php new file mode 100644 index 000000000..f0e994806 --- /dev/null +++ b/src/testing/src/Constraints/SeeInHtml.php @@ -0,0 +1,132 @@ +normalize($this->content); + + $position = 0; + + foreach ($values as $value) { + if ($value === '') { + continue; + } + + $normalizedValue = $this->normalize($value); + + if ($normalizedValue === '') { + $this->failedValue = $value; + + return false; + } + + $valuePosition = mb_strpos($normalizedContent, $normalizedValue, $position); + + if ($this->negate) { + if ($valuePosition !== false) { + $this->failedValue = $value; + + return false; + } + + continue; + } + + if ($valuePosition === false || $valuePosition < $position) { + $this->failedValue = $value; + + return false; + } + + if ($this->ordered) { + $position = $valuePosition + mb_strlen($normalizedValue); + } + } + + return true; + } + + /** + * Get the description of the failure. + * + * @param array $values + */ + public function failureDescription($values): string + { + if ($this->normalize((string) $this->failedValue) === '') { + return sprintf( + 'the expected value "%s" contains visible text', + $this->failedValue + ); + } + + if ($this->negate) { + return sprintf( + '\'%s\' does not contain "%s"', + $this->content, + $this->failedValue + ); + } + + return sprintf( + '\'%s\' contains "%s"%s', + $this->content, + $this->failedValue, + $this->ordered ? ' in specified order' : '' + ); + } + + /** + * Normalize the given value. + */ + protected function normalize(string $value): string + { + $value = trim(html_entity_decode(strip_tags($value), ENT_QUOTES, 'UTF-8')); + $normalized = preg_replace('/\s+/u', ' ', $value); + + if ($normalized !== null) { + return $normalized; + } + + /** @var string $normalized */ + $normalized = preg_replace('/\s+/', ' ', $value); + + return $normalized; + } + + /** + * Get a string representation of the object. + */ + public function toString(): string + { + return (new ReflectionClass($this))->name; + } +} diff --git a/src/testing/src/Constraints/SeeInOrder.php b/src/testing/src/Constraints/SeeInOrder.php index 0bb6c1c4a..58651214f 100644 --- a/src/testing/src/Constraints/SeeInOrder.php +++ b/src/testing/src/Constraints/SeeInOrder.php @@ -36,7 +36,7 @@ public function matches($values): bool $position = 0; foreach ($values as $value) { - if (empty($value)) { + if ($value === '') { continue; } @@ -64,7 +64,7 @@ public function matches($values): bool public function failureDescription($values): string { return sprintf( - 'Failed asserting that \'%s\' contains "%s" in specified order.', + '\'%s\' contains "%s" in specified order', $this->content, $this->failedValue ); diff --git a/src/testing/src/TestComponent.php b/src/testing/src/TestComponent.php index 0bcb3e60d..13e604faf 100644 --- a/src/testing/src/TestComponent.php +++ b/src/testing/src/TestComponent.php @@ -4,8 +4,10 @@ namespace Hypervel\Testing; +use Hypervel\Support\Arr; use Hypervel\Support\Traits\Macroable; use Hypervel\Testing\Assert as PHPUnit; +use Hypervel\Testing\Constraints\SeeInHtml; use Hypervel\Testing\Constraints\SeeInOrder; use Hypervel\View\Component; use Hypervel\View\View; @@ -38,22 +40,39 @@ public function __construct(Component $component, View $view) } /** - * Assert that the given string is contained within the rendered component. + * Assert that the given string or array of strings are contained within the rendered component. * + * @param list|string $value * @return $this */ - public function assertSee(string $value, bool $escape = true): static + public function assertSee(array|string $value, bool $escape = true): static { - $value = $escape ? e($value) : $value; + $value = Arr::wrap($value); - PHPUnit::assertStringContainsString((string) $value, $this->rendered); + $values = $escape ? array_map(e(...), $value) : $value; + + foreach ($values as $value) { + PHPUnit::assertStringContainsString((string) $value, $this->rendered); + } return $this; } + /** + * Assert that the given HTML string or array of HTML strings are contained within the rendered component. + * + * @param list|string $value + * @return $this + */ + public function assertSeeHtml(array|string $value): static + { + return $this->assertSee($value, false); + } + /** * Assert that the given strings are contained in order within the rendered component. * + * @param list $values * @return $this */ public function assertSeeInOrder(array $values, bool $escape = true): static @@ -66,15 +85,29 @@ public function assertSeeInOrder(array $values, bool $escape = true): static } /** - * Assert that the given string is contained within the rendered component text. + * Assert that the given HTML strings are contained in order within the rendered component. * + * @param list $values * @return $this */ - public function assertSeeText(string $value, bool $escape = true): static + public function assertSeeHtmlInOrder(array $values): static { - $value = $escape ? e($value) : $value; + return $this->assertSeeInOrder($values, false); + } - PHPUnit::assertStringContainsString((string) $value, strip_tags($this->rendered)); + /** + * Assert that the given string or array of strings are contained within the rendered component text. + * + * @param list|string $value + * @return $this + */ + public function assertSeeText(array|string $value, bool $escape = true): static + { + $value = Arr::wrap($value); + + $values = $escape ? array_map(e(...), $value) : $value; + + PHPUnit::assertThat($values, new SeeInHtml($this->rendered)); return $this; } @@ -82,41 +115,61 @@ public function assertSeeText(string $value, bool $escape = true): static /** * Assert that the given strings are contained in order within the rendered component text. * + * @param list $values * @return $this */ public function assertSeeTextInOrder(array $values, bool $escape = true): static { $values = $escape ? array_map(e(...), $values) : $values; - PHPUnit::assertThat($values, new SeeInOrder(strip_tags($this->rendered))); + PHPUnit::assertThat($values, new SeeInHtml($this->rendered, true)); return $this; } /** - * Assert that the given string is not contained within the rendered component. + * Assert that the given string or array of strings are not contained within the rendered component. * + * @param list|string $value * @return $this */ - public function assertDontSee(string $value, bool $escape = true): static + public function assertDontSee(array|string $value, bool $escape = true): static { - $value = $escape ? e($value) : $value; + $value = Arr::wrap($value); - PHPUnit::assertStringNotContainsString((string) $value, $this->rendered); + $values = $escape ? array_map(e(...), $value) : $value; + + foreach ($values as $value) { + PHPUnit::assertStringNotContainsString((string) $value, $this->rendered); + } return $this; } /** - * Assert that the given string is not contained within the rendered component text. + * Assert that the given HTML string or array of HTML strings are not contained within the rendered component. * + * @param list|string $value * @return $this */ - public function assertDontSeeText(string $value, bool $escape = true): static + public function assertDontSeeHtml(array|string $value): static { - $value = $escape ? e($value) : $value; + return $this->assertDontSee($value, false); + } + + /** + * Assert that the given string or array of strings are not contained within the rendered component text. + * + * @param list|string $value + * @return $this + */ + public function assertDontSeeText(array|string $value, bool $escape = true): static + { + $value = Arr::wrap($value); + + $values = $escape ? array_map(e(...), $value) : $value; - PHPUnit::assertStringNotContainsString((string) $value, strip_tags($this->rendered)); + PHPUnit::assertThat($values, new SeeInHtml($this->rendered, negate: true)); return $this; } diff --git a/src/testing/src/TestView.php b/src/testing/src/TestView.php index 8f2461d43..7f99e2ea3 100644 --- a/src/testing/src/TestView.php +++ b/src/testing/src/TestView.php @@ -10,6 +10,7 @@ use Hypervel\Support\Arr; use Hypervel\Support\Traits\Macroable; use Hypervel\Testing\Assert as PHPUnit; +use Hypervel\Testing\Constraints\SeeInHtml; use Hypervel\Testing\Constraints\SeeInOrder; use Hypervel\View\View; use Stringable; @@ -117,22 +118,39 @@ public function assertViewEmpty(): static } /** - * Assert that the given string is contained within the view. + * Assert that the given string or array of strings are contained within the view. * + * @param list|string $value * @return $this */ - public function assertSee(string $value, bool $escape = true): static + public function assertSee(array|string $value, bool $escape = true): static { - $value = $escape ? e($value) : $value; + $value = Arr::wrap($value); - PHPUnit::assertStringContainsString((string) $value, $this->rendered); + $values = $escape ? array_map(e(...), $value) : $value; + + foreach ($values as $value) { + PHPUnit::assertStringContainsString((string) $value, $this->rendered); + } return $this; } + /** + * Assert that the given HTML string or array of HTML strings are contained within the view. + * + * @param list|string $value + * @return $this + */ + public function assertSeeHtml(array|string $value): static + { + return $this->assertSee($value, false); + } + /** * Assert that the given strings are contained in order within the view. * + * @param list $values * @return $this */ public function assertSeeInOrder(array $values, bool $escape = true): static @@ -145,15 +163,29 @@ public function assertSeeInOrder(array $values, bool $escape = true): static } /** - * Assert that the given string is contained within the view text. + * Assert that the given HTML strings are contained in order within the view. * + * @param list $values * @return $this */ - public function assertSeeText(string $value, bool $escape = true): static + public function assertSeeHtmlInOrder(array $values): static { - $value = $escape ? e($value) : $value; + return $this->assertSeeInOrder($values, false); + } - PHPUnit::assertStringContainsString((string) $value, strip_tags($this->rendered)); + /** + * Assert that the given string or array of strings are contained within the view text. + * + * @param list|string $value + * @return $this + */ + public function assertSeeText(array|string $value, bool $escape = true): static + { + $value = Arr::wrap($value); + + $values = $escape ? array_map(e(...), $value) : $value; + + PHPUnit::assertThat($values, new SeeInHtml($this->rendered)); return $this; } @@ -161,41 +193,61 @@ public function assertSeeText(string $value, bool $escape = true): static /** * Assert that the given strings are contained in order within the view text. * + * @param list $values * @return $this */ public function assertSeeTextInOrder(array $values, bool $escape = true): static { $values = $escape ? array_map(e(...), $values) : $values; - PHPUnit::assertThat($values, new SeeInOrder(strip_tags($this->rendered))); + PHPUnit::assertThat($values, new SeeInHtml($this->rendered, true)); return $this; } /** - * Assert that the given string is not contained within the view. + * Assert that the given string or array of strings are not contained within the view. * + * @param list|string $value * @return $this */ - public function assertDontSee(string $value, bool $escape = true): static + public function assertDontSee(array|string $value, bool $escape = true): static { - $value = $escape ? e($value) : $value; + $value = Arr::wrap($value); - PHPUnit::assertStringNotContainsString((string) $value, $this->rendered); + $values = $escape ? array_map(e(...), $value) : $value; + + foreach ($values as $value) { + PHPUnit::assertStringNotContainsString((string) $value, $this->rendered); + } return $this; } /** - * Assert that the given string is not contained within the view text. + * Assert that the given HTML string or array of HTML strings are not contained within the view. * + * @param list|string $value * @return $this */ - public function assertDontSeeText(string $value, bool $escape = true): static + public function assertDontSeeHtml(array|string $value): static { - $value = $escape ? e($value) : $value; + return $this->assertDontSee($value, false); + } + + /** + * Assert that the given string or array of strings are not contained within the view text. + * + * @param list|string $value + * @return $this + */ + public function assertDontSeeText(array|string $value, bool $escape = true): static + { + $value = Arr::wrap($value); + + $values = $escape ? array_map(e(...), $value) : $value; - PHPUnit::assertStringNotContainsString((string) $value, strip_tags($this->rendered)); + PHPUnit::assertThat($values, new SeeInHtml($this->rendered, negate: true)); return $this; } diff --git a/tests/Mail/MailMailableAssertionsTest.php b/tests/Mail/MailMailableAssertionsTest.php index 819cf804f..098aab394 100644 --- a/tests/Mail/MailMailableAssertionsTest.php +++ b/tests/Mail/MailMailableAssertionsTest.php @@ -224,6 +224,7 @@ public function testMailableAssertSeeInOrderInHtmlWithApostropheFailsWhenAbsentI $mailable = new MailableAssertionsStub; $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('contains "First Item" in specified order'); $mailable->assertSeeInOrderInHtml([ 'It\'s a wonderful day', @@ -231,6 +232,26 @@ public function testMailableAssertSeeInOrderInHtmlWithApostropheFailsWhenAbsentI 'Sixth Item', ]); } + + public function testMailableOrderedTextAssertionsDoNotSkipStringZero(): void + { + $mailable = new MailableAssertionsStub; + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('contains "0" in specified order'); + + $mailable->assertSeeInOrderInText(['Sixth Item', '0']); + } + + public function testMailableOrderedHtmlAssertionsDoNotSkipStringZero(): void + { + $mailable = new MailableAssertionsStub; + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('contains "0" in specified order'); + + $mailable->assertSeeInOrderInHtml(['Sixth Item', '0']); + } } class MailableAssertionsBladeEscapedStub extends Mailable diff --git a/tests/Testing/SeeInHtmlTest.php b/tests/Testing/SeeInHtmlTest.php new file mode 100644 index 000000000..c879834f8 --- /dev/null +++ b/tests/Testing/SeeInHtmlTest.php @@ -0,0 +1,144 @@ +assertTrue($constraint->matches(['

Hello World

'])); + $this->assertTrue($constraint->matches(['

Hello World

'])); + $this->assertTrue($constraint->matches(['

Hello World

'])); + $this->assertTrue((new SeeInHtml('

Hello World

'))->matches(['Hello World'])); + } + + #[DataProvider('unicodeWhitespaceCharacters')] + public function testCollapsesRawUnicodeWhitespace(string $whitespace): void + { + $constraint = new SeeInHtml('Hello World'); + + $this->assertTrue($constraint->matches(["

Hello{$whitespace}World

"])); + } + + /** + * Provide Unicode whitespace characters. + * + * @return array + */ + public static function unicodeWhitespaceCharacters(): array + { + return [ + 'no-break space (U+00A0)' => ["\u{00A0}"], + 'en space (U+2002)' => ["\u{2002}"], + 'em space (U+2003)' => ["\u{2003}"], + 'thin space (U+2009)' => ["\u{2009}"], + 'ideographic space (U+3000)' => ["\u{3000}"], + ]; + } + + public function testCollapsesMultipleAsciiWhitespace(): void + { + $constraint = new SeeInHtml('Hello World'); + + $this->assertTrue($constraint->matches(['

Hello World

'])); + $this->assertTrue($constraint->matches(["

Hello\tWorld

"])); + $this->assertTrue($constraint->matches(["

Hello\nWorld

"])); + $this->assertTrue($constraint->matches(["

Hello \t\n World

"])); + } + + public function testFailsWhenValueIsAbsent(): void + { + $constraint = new SeeInHtml('Hello World'); + + $this->assertFalse($constraint->matches(['

Goodbye World

'])); + } + + public function testNegateInvertsTheAssertion(): void + { + $constraint = new SeeInHtml('Hello World', ordered: false, negate: true); + + $this->assertTrue($constraint->matches(['

Goodbye World

'])); + $this->assertFalse($constraint->matches(['

Hello World

'])); + } + + public function testOrderedRespectsSequenceAcrossUnicodeWhitespace(): void + { + $constraint = new SeeInHtml('Hello beautiful World', ordered: true); + + $this->assertTrue($constraint->matches(['Hello', 'beautiful', 'World'])); + $this->assertFalse($constraint->matches(['World', 'Hello'])); + } + + public function testAssertsStringZeroAndSkipsOnlyTheRawEmptyString(): void + { + $constraint = new SeeInHtml('

0

'); + + $this->assertTrue($constraint->matches(['', '0'])); + $this->assertFalse((new SeeInHtml('

one

'))->matches(['0'])); + } + + public function testRejectsExpectedValuesWithoutVisibleText(): void + { + $constraint = new SeeInHtml('

Hello World

'); + + $this->assertFalse($constraint->matches([" \t\n "])); + $this->assertFalse($constraint->matches([''])); + $this->assertFalse((new SeeInHtml('

Hello World

', negate: true))->matches(['
'])); + } + + public function testRetainsByteWiseMatchingForMalformedUtf8(): void + { + $constraint = new SeeInHtml("

Hello \xFF World

"); + + $this->assertTrue($constraint->matches(["Hello \xFF World"])); + $this->assertFalse($constraint->matches(["Goodbye \xFF World"])); + } + + public function testFailureMessagesContainOnePrefixAndOneTerminalPeriod(): void + { + $this->assertConstraintFailure( + new SeeInHtml('Hello World'), + ['Goodbye World'], + 'Failed asserting that \'Hello World\' contains "Goodbye World".', + ); + $this->assertConstraintFailure( + new SeeInHtml('Hello World', ordered: true), + ['World', 'Hello'], + 'Failed asserting that \'Hello World\' contains "Hello" in specified order.', + ); + $this->assertConstraintFailure( + new SeeInHtml('Hello World', negate: true), + ['Hello World'], + 'Failed asserting that \'Hello World\' does not contain "Hello World".', + ); + $this->assertConstraintFailure( + new SeeInHtml('Hello World'), + [''], + 'Failed asserting that the expected value "" contains visible text.', + ); + } + + /** + * Assert a constraint fails with the expected complete PHPUnit message. + * + * @param list $values + */ + protected function assertConstraintFailure(SeeInHtml $constraint, array $values, string $message): void + { + try { + $constraint->evaluate($values); + $this->fail('The constraint did not fail.'); + } catch (ExpectationFailedException $exception) { + $this->assertSame($message, $exception->getMessage()); + } + } +} diff --git a/tests/Testing/TestViewTest.php b/tests/Testing/TestViewTest.php index c97da7606..7c446da8f 100644 --- a/tests/Testing/TestViewTest.php +++ b/tests/Testing/TestViewTest.php @@ -6,8 +6,10 @@ use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Database\Eloquent\Model; +use Hypervel\Testing\TestComponent; use Hypervel\Testing\TestView; use Hypervel\Tests\TestCase; +use Hypervel\View\Component; use Hypervel\View\View; use Mockery as m; use PHPUnit\Framework\AssertionFailedError; @@ -86,10 +88,60 @@ public function testAssertViewHasReportsAMissingCollectionKeyAsAnAssertionFailur $this->makeTestView(['foos' => $actual])->assertViewHas('foos', $expected); } - private function makeTestView(array $data): TestView + public function testRenderedViewAssertionsSupportArraysHtmlAndNormalizedText(): void + { + $view = $this->makeTestView( + [], + "

Hello beautiful\u{2003}World

0
", + ); + + $view + ->assertSee(['beautiful', 'World']) + ->assertSeeHtml(['
', 'beautiful']) + ->assertSeeHtmlInOrder(['
', '0']) + ->assertSeeText(['Hello beautiful World', '0']) + ->assertSeeTextInOrder(['Hello', 'beautiful', 'World', '0']) + ->assertDontSee(['Goodbye', '