diff --git a/phpunit.xml.dist b/phpunit.xml.dist index eefaa1b079..758d559522 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -24,17 +24,14 @@ - + + diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index 18748f174a..090b7077bb 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -912,6 +912,8 @@ abstract class TestCase extends BaseTestCase Add Hypervel's PHPUnit extension to the package's `phpunit.xml` file. Orchestra Testbench resets framework state from its test case, while Hypervel resets it from this extension. See [Test State Cleanup](/docs/{{version}}/testing#test-state-cleanup). +Remove the unused `--custom-argument` flag from package test scripts. Hypervel's `package:test` command uses `-c` as a shortcut for `--configuration`, matching PHPUnit. See [Running Package Tests](/docs/{{version}}/testbench#running-package-tests). + For package testing details, see the [Testbench documentation](/docs/{{version}}/testbench). diff --git a/src/docs/testbench.md b/src/docs/testbench.md index b6e11c5889..d114d66db6 100644 --- a/src/docs/testbench.md +++ b/src/docs/testbench.md @@ -1180,7 +1180,7 @@ The command supports the following options: | Option | Description | | --- | --- | | `--without-tty` | Disable TTY output. | -| `--configuration=` | Read PHPUnit configuration from the given XML file. | +| `-c`, `--configuration=` | Read PHPUnit configuration from the given XML file or directory. | | `--coverage` | Collect code coverage. | | `--min=` | Fail when coverage is below the given percentage. | | `--parallel` | Run tests in parallel through ParaTest. | diff --git a/src/foundation/src/Bootstrap/ConfigureTerminalDimensions.php b/src/foundation/src/Bootstrap/ConfigureTerminalDimensions.php index 61540c83ff..53d52f9a7f 100644 --- a/src/foundation/src/Bootstrap/ConfigureTerminalDimensions.php +++ b/src/foundation/src/Bootstrap/ConfigureTerminalDimensions.php @@ -5,12 +5,16 @@ namespace Hypervel\Foundation\Bootstrap; use Hypervel\Contracts\Foundation\Application; +use ReflectionProperty; +use Symfony\Component\Console\Terminal; /** * Prevent Symfony Terminal from repeatedly spawning `stty` when headless. * - * Its fallback dimensions are not cached, so every lookup otherwise enters - * Swoole's hooked subprocess-wait path. + * Symfony does not cache failed dimension probes, so each lookup otherwise + * enters Swoole's hooked subprocess wait, which can hang even after stty exits. + * + * @see https://github.com/swoole/swoole-src/issues/6328 */ class ConfigureTerminalDimensions { @@ -18,17 +22,27 @@ class ConfigureTerminalDimensions * Configure deterministic dimensions for a headless terminal. */ public function bootstrap(Application $app): void + { + static::configure(); + } + + /** + * Configure headless fallback dimensions without modifying the environment. + */ + public static function configure(): void { if (defined('STDOUT') && stream_isatty(STDOUT)) { return; } - if (getenv('COLUMNS') === false) { - putenv('COLUMNS=80'); - } + // Symfony has no dimension setter. Fill its cache so defaults cannot mask + // later environment loads; getWidth()/getHeight() still prefer explicit values. + foreach (['width' => 80, 'height' => 24] as $name => $default) { + $property = new ReflectionProperty(Terminal::class, $name); - if (getenv('LINES') === false) { - putenv('LINES=24'); + if ($property->getValue() === null) { + $property->setValue(null, $default); + } } } } diff --git a/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php b/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php index 2d8c0e40a3..eb38e870b6 100644 --- a/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php +++ b/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php @@ -10,6 +10,7 @@ use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Database\Eloquent\Model; use Hypervel\Engine\Coroutine as EngineCoroutine; +use Hypervel\Foundation\Bootstrap\ConfigureTerminalDimensions; use PHPUnit\Framework\AssertionFailedError; use PHPUnit\Framework\Attributes\PostCondition; use PHPUnit\Framework\IncompleteTest; @@ -45,6 +46,10 @@ trait RunTestsInCoroutine */ protected function invokeTestMethod(string $methodName, array $testArguments): mixed { + // Initialize each test process before it enters hooked coroutines, + // including tests that start their own run(). + ConfigureTerminalDimensions::configure(); + if (Coroutine::getCid() !== -1 || ! $this->runsTestsInCoroutine()) { return parent::invokeTestMethod($methodName, $testArguments); } diff --git a/src/testbench/README.md b/src/testbench/README.md index fc0f92cb0d..0823a568bc 100644 --- a/src/testbench/README.md +++ b/src/testbench/README.md @@ -13,6 +13,8 @@ Hypervel applies every `#[WithConfig]` value before service providers register. Hypervel does not forward the parent Testbench CLI application's full runtime environment to `package:test` subprocesses. In package-test mode, package and workbench environment files are copied into the child runtime application, while shell or CI environment variables, PHPUnit XML values, and Testbench YAML `env` values continue to reach package-test child processes through their normal channels. +The `package:test` command uses `-c` as the shortcut for `--configuration`, matching PHPUnit and the application's `test` command. Orchestra's unused `--custom-argument` flag is omitted. + Testbench writes boolean environment values as `true` and `false` instead of `(true)` and `(false)`, so libraries that read environment variables directly can interpret them. Laravel-style `env()` behavior is unchanged. Hypervel does not use Orchestra's `TESTBENCH_APP_BASE_PATH` channel. Each process owns its runtime application identity through `BASE_PATH`, remote child processes receive the current worker clone through `TESTBENCH_BASE_PATH`, and `APP_BASE_PATH` remains the explicit user override. diff --git a/src/testbench/src/Concerns/CreatesApplication.php b/src/testbench/src/Concerns/CreatesApplication.php index e562963800..faf4877de3 100644 --- a/src/testbench/src/Concerns/CreatesApplication.php +++ b/src/testbench/src/Concerns/CreatesApplication.php @@ -13,6 +13,7 @@ use Hypervel\Di\Bootstrap\GenerateProxies; use Hypervel\Foundation\Application; use Hypervel\Foundation\Bootstrap\BootProviders; +use Hypervel\Foundation\Bootstrap\ConfigureTerminalDimensions; use Hypervel\Foundation\Bootstrap\HandleExceptions as FoundationHandleExceptions; use Hypervel\Foundation\Bootstrap\LoadConfiguration as FoundationLoadConfiguration; use Hypervel\Foundation\Bootstrap\LoadEnvironmentVariables; @@ -267,6 +268,7 @@ public function createApplication(): ApplicationContract $this->resolveApplicationBindings($app); $this->resolveApplicationExceptionHandler($app); $this->resolveApplicationEnvironmentVariables($app); + $app->make(ConfigureTerminalDimensions::class)->bootstrap($app); $this->resolveApplicationConfiguration($app); // Must run after resolveApplicationConfiguration() because LoadConfiguration's diff --git a/src/testbench/src/Foundation/Console/TestCommand.php b/src/testbench/src/Foundation/Console/TestCommand.php index 9557751a7a..f50efa26a4 100644 --- a/src/testbench/src/Foundation/Console/TestCommand.php +++ b/src/testbench/src/Foundation/Console/TestCommand.php @@ -21,10 +21,13 @@ class TestCommand extends TestCommandBase { /** * The name and signature of the console command. + * + * Orchestra's unused --custom-argument flag is omitted so -c selects the + * same configuration for profiling, cache settings, and the PHPUnit child. */ protected ?string $signature = 'package:test {--without-tty : Disable output to TTY} - {--configuration= : Read configuration from XML file} + {--c|configuration= : Read configuration from XML file} {--coverage : Indicates whether coverage information should be collected} {--min= : Indicates the minimum threshold enforcement for coverage} {--p|parallel : Indicates if the tests should run in parallel} @@ -33,7 +36,6 @@ class TestCommand extends TestCommandBase {--drop-databases : Indicates if the test databases should be dropped} {--without-databases : Indicates if database configuration should be performed} {--without-cache : Do not isolate cache and rate limiter prefixes} - {--c|--custom-argument : Add custom env variables} '; /** diff --git a/src/testing/src/Console/TestCommand.php b/src/testing/src/Console/TestCommand.php index 77c1f598a5..c801599183 100644 --- a/src/testing/src/Console/TestCommand.php +++ b/src/testing/src/Console/TestCommand.php @@ -14,7 +14,7 @@ class TestCommand extends TestCommandBase */ protected ?string $signature = 'test {--without-tty : Disable output to TTY} - {--configuration= : Read configuration from XML file} + {--c|configuration= : Read configuration from XML file} {--coverage : Indicates whether coverage information should be collected} {--min= : Indicates the minimum threshold enforcement for coverage} {--p|parallel : Indicates if the tests should run in parallel} diff --git a/src/testing/src/Console/TestCommandBase.php b/src/testing/src/Console/TestCommandBase.php index f2b7085539..e8af8380c2 100644 --- a/src/testing/src/Console/TestCommandBase.php +++ b/src/testing/src/Console/TestCommandBase.php @@ -234,13 +234,24 @@ protected function usingPest(): bool public function phpUnitConfigurationFile(): string { $configuration = $this->option('configuration'); - $configurationFile = str_replace('./', '', is_string($configuration) && $configuration !== '' ? $configuration : 'phpunit.xml'); + $configurationFiles = ['phpunit.xml', 'phpunit.dist.xml', 'phpunit.xml.dist']; + $directory = $this->basePath(); + $fallback = './'; - $file = (new Collection([ - $this->basePath($configurationFile), - $this->basePath("{$configurationFile}.dist"), - ]))->filter(static fn (string $path): bool => is_file($path)) - ->first() ?? './'; + if (is_string($configuration) && $configuration !== '') { + $fallback = str_starts_with($configuration, DIRECTORY_SEPARATOR) ? $configuration : $this->basePath($configuration); + + if (is_dir($fallback)) { + $directory = $fallback; + } else { + $configurationFiles = [$fallback, "{$fallback}.dist"]; + } + } + + $file = (new Collection($configurationFiles)) + ->map(fn (string $path): string => str_starts_with($path, DIRECTORY_SEPARATOR) ? $path : join_paths($directory, $path)) + ->filter(static fn (string $path): bool => is_file($path)) + ->first() ?? $fallback; return $this->option('profile') ? $this->profileConfigurationFile($file) : $file; } @@ -291,9 +302,18 @@ protected function paratestArguments(array $options): array $inputDefinition = new InputDefinition; Options::setInputDefinition($inputDefinition); $input = new ArgvInput(['paratest', ...$arguments], $inputDefinition); - $paraTestOptions = Options::fromConsoleInput($input, $this->basePath()); - if (! $paraTestOptions->configuration->hasCoverageCacheDirectory()) { + // Options::fromConsoleInput() replaces PHPUnit's active configuration, + // which can break later isolated tests. Only inspect the cache setting here. + $hasCacheDirectory = $input->getOption('cache-directory') !== null; + + if (! $hasCacheDirectory && is_file($file)) { + $document = new DOMDocument; + $document->load($file); + $hasCacheDirectory = $document->documentElement?->hasAttribute('cacheDirectory') ?? false; + } + + if (! $hasCacheDirectory) { $arguments[] = '--cache-directory'; $arguments[] = sys_get_temp_dir() . DIRECTORY_SEPARATOR . '__hypervel_test_cache_directory'; } @@ -312,6 +332,14 @@ protected function paratestArguments(array $options): array */ protected function filterForwardedArguments(array $arguments, array $exact = [], array $prefixes = [], array $valueOptions = []): array { + // A forwarded configuration shortcut would override the profiled XML file. + $configurationShortcut = $this->getDefinition()->getOption('configuration')->getShortcut(); + $configurationShortcut = $configurationShortcut === null ? null : "-{$configurationShortcut}"; + + if ($configurationShortcut !== null && in_array('--configuration', $valueOptions, true)) { + $valueOptions[] = $configurationShortcut; + } + $filtered = []; $skipNextArgument = false; @@ -329,7 +357,8 @@ protected function filterForwardedArguments(array $arguments, array $exact = [], continue 2; } - if (Str::startsWith($argument, "{$option}=")) { + if (Str::startsWith($argument, "{$option}=") + || ($option === $configurationShortcut && Str::startsWith($argument, $option))) { continue 2; } } @@ -513,7 +542,7 @@ protected function profileConfigurationFile(string $file): string return $this->temporaryConfigurationFile; } - if ($file === './') { + if (! is_file($file)) { return $file; } diff --git a/tests/Foundation/Bootstrap/ConfigureTerminalDimensionsTest.php b/tests/Foundation/Bootstrap/ConfigureTerminalDimensionsTest.php index 7e36c92b96..ac308abbf6 100644 --- a/tests/Foundation/Bootstrap/ConfigureTerminalDimensionsTest.php +++ b/tests/Foundation/Bootstrap/ConfigureTerminalDimensionsTest.php @@ -6,15 +6,21 @@ use Hypervel\Foundation\Application; use Hypervel\Foundation\Bootstrap\ConfigureTerminalDimensions; -use Hypervel\Foundation\Bootstrap\LoadEnvironmentVariables; +use Hypervel\Foundation\Bootstrap\RegisterProviders; use Hypervel\Foundation\Console\Kernel as ConsoleKernel; use Hypervel\Foundation\Http\Kernel as HttpKernel; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RunInSeparateProcess; use ReflectionProperty; +use Symfony\Component\Console\Terminal; +use Symfony\Component\Process\Process; class ConfigureTerminalDimensionsTest extends TestCase { + // Keep the PTY child outside Swoole's hooked subprocess wait. + protected bool $runTestsInCoroutine = false; + #[RunInSeparateProcess] public function testPreservesExistingTerminalDimensions(): void { @@ -23,10 +29,13 @@ public function testPreservesExistingTerminalDimensions(): void $this->assertFalse(stream_isatty(STDOUT)); + $this->resetTerminalDimensions(); (new ConfigureTerminalDimensions)->bootstrap(new Application); $this->assertSame('200', getenv('COLUMNS')); $this->assertSame('50', getenv('LINES')); + $this->assertSame(200, (new Terminal)->getWidth()); + $this->assertSame(50, (new Terminal)->getHeight()); } #[RunInSeparateProcess] @@ -39,32 +48,92 @@ public function testConfiguresTerminalDimensionsWhenTheyAreAbsent(): void $this->assertFalse(getenv('LINES')); $this->assertFalse(stream_isatty(STDOUT)); + $this->resetTerminalDimensions(); (new ConfigureTerminalDimensions)->bootstrap(new Application); - $this->assertSame('80', getenv('COLUMNS')); - $this->assertSame('24', getenv('LINES')); + $this->assertFalse(getenv('COLUMNS')); + $this->assertFalse(getenv('LINES')); + // Symfony also returns 80 after a failed probe; ensure neither lookup needs one. + $this->assertSame(80, (new ReflectionProperty(Terminal::class, 'width'))->getValue()); + $this->assertSame(24, (new ReflectionProperty(Terminal::class, 'height'))->getValue()); + $this->assertSame(80, (new Terminal)->getWidth()); + $this->assertSame(24, (new Terminal)->getHeight()); } - public function testKernelsIncludeTerminalDimensionsBootstrapper(): void + #[RunInSeparateProcess] + #[DataProvider('partialDimensions')] + public function testPreservesEachDimensionIndependently(string $preset, string $missing, int $expectedWidth, int $expectedHeight): void { - foreach ([HttpKernel::class, ConsoleKernel::class] as $kernel) { - $this->assertContains( - ConfigureTerminalDimensions::class, - $this->getBootstrappers($kernel), - ); - } + putenv("{$preset}=120"); + putenv($missing); + + $this->resetTerminalDimensions(); + (new ConfigureTerminalDimensions)->bootstrap(new Application); + + $this->assertSame('120', getenv($preset)); + $this->assertFalse(getenv($missing)); + $this->assertSame($expectedWidth, (new Terminal)->getWidth()); + $this->assertSame($expectedHeight, (new Terminal)->getHeight()); + } + + /** + * Provide each individually configured terminal dimension. + */ + public static function partialDimensions(): array + { + return [ + 'width' => ['COLUMNS', 'LINES', 120, 24], + 'height' => ['LINES', 'COLUMNS', 80, 120], + ]; + } + + #[RunInSeparateProcess] + public function testPreservesRestoredEnvironmentDimensions(): void + { + putenv('COLUMNS=120'); + putenv('LINES=40'); + $this->resetTerminalDimensions(); + + $this->withEnvironmentValues(['COLUMNS' => null, 'LINES' => null], function (): void { + ConfigureTerminalDimensions::configure(); + + $this->assertFalse(getenv('COLUMNS')); + $this->assertFalse(getenv('LINES')); + $this->assertSame(80, (new Terminal)->getWidth()); + $this->assertSame(24, (new Terminal)->getHeight()); + }); + + ConfigureTerminalDimensions::configure(); + + $this->assertSame('120', getenv('COLUMNS')); + $this->assertSame('40', getenv('LINES')); + $this->assertSame(120, (new Terminal)->getWidth()); + $this->assertSame(40, (new Terminal)->getHeight()); } - public function testTerminalDimensionsAreConfiguredAfterEnvironmentVariablesAreLoaded(): void + public function testLeavesRealTerminalDimensionsUnchanged(): void + { + $process = new Process( + [PHP_BINARY, __DIR__ . '/../Fixtures/configure-terminal-dimensions.php'], + env: ['COLUMNS' => false, 'LINES' => false], + timeout: 10, + ); + $process->setPty(true); + $process->mustRun(); + + $this->assertSame([true, false, false, null, null], json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR)); + } + + public function testTerminalDimensionsAreConfiguredBeforeProvidersAreRegistered(): void { foreach ([HttpKernel::class, ConsoleKernel::class] as $kernel) { $bootstrappers = $this->getBootstrappers($kernel); - $environmentIndex = array_search(LoadEnvironmentVariables::class, $bootstrappers, true); + $providersIndex = array_search(RegisterProviders::class, $bootstrappers, true); $terminalIndex = array_search(ConfigureTerminalDimensions::class, $bootstrappers, true); - $this->assertIsInt($environmentIndex); + $this->assertIsInt($providersIndex); $this->assertIsInt($terminalIndex); - $this->assertGreaterThan($environmentIndex, $terminalIndex); + $this->assertLessThan($providersIndex, $terminalIndex); } } @@ -77,4 +146,13 @@ private function getBootstrappers(string $kernelClass): array return $property->getDefaultValue(); } + + /** + * Clear dimensions supplied by the test runner before exercising bootstrap. + */ + private function resetTerminalDimensions(): void + { + (new ReflectionProperty(Terminal::class, 'width'))->setValue(null, null); + (new ReflectionProperty(Terminal::class, 'height'))->setValue(null, null); + } } diff --git a/tests/Foundation/Fixtures/configure-terminal-dimensions.php b/tests/Foundation/Fixtures/configure-terminal-dimensions.php new file mode 100644 index 0000000000..6706782296 --- /dev/null +++ b/tests/Foundation/Fixtures/configure-terminal-dimensions.php @@ -0,0 +1,18 @@ +getValue(), + (new ReflectionProperty(Terminal::class, 'height'))->getValue(), +], JSON_THROW_ON_ERROR); diff --git a/tests/Testbench/TerminalDimensionsTest.php b/tests/Testbench/TerminalDimensionsTest.php new file mode 100644 index 0000000000..2ff093b9e1 --- /dev/null +++ b/tests/Testbench/TerminalDimensionsTest.php @@ -0,0 +1,84 @@ +setValue(null, null); + (new ReflectionProperty(Terminal::class, 'height'))->setValue(null, null); + + $this->assertFalse(stream_isatty(STDOUT)); + + $this->refreshApplication(); + + $this->assertFalse(getenv('COLUMNS')); + $this->assertFalse(getenv('LINES')); + $this->assertSame(80, (new Terminal)->getWidth()); + $this->assertSame(24, (new Terminal)->getHeight()); + } + + public function testEnvironmentFileOverridesEarlierTerminalDefaults(): void + { + $this->environmentPath = ParallelTesting::tempDir('TestbenchTerminalDimensions'); + $files = new Filesystem; + $files->deleteDirectory($this->environmentPath); + $files->ensureDirectoryExists($this->environmentPath); + + try { + $files->put($this->environmentPath . '/.env', "COLUMNS=120\nLINES=40\n"); + DotenvManager::flushState(); + putenv('COLUMNS'); + putenv('LINES'); + unset($_ENV['COLUMNS'], $_ENV['LINES'], $_SERVER['COLUMNS'], $_SERVER['LINES']); + + // A unit test can initialize terminal fallbacks before it boots an application. + ConfigureTerminalDimensions::configure(); + + $this->refreshApplication(); + + $this->assertSame('120', getenv('COLUMNS')); + $this->assertSame('40', getenv('LINES')); + $this->assertSame('120', Env::get('COLUMNS')); + $this->assertSame('40', Env::get('LINES')); + $this->assertSame(120, (new Terminal)->getWidth()); + $this->assertSame(40, (new Terminal)->getHeight()); + } finally { + $files->deleteDirectory($this->environmentPath); + } + } + + /** + * Resolve the application with the test's isolated environment directory. + */ + protected function resolveApplication(): ApplicationContract + { + $app = parent::resolveApplication(); + + if ($this->environmentPath !== null) { + $app->useEnvironmentPath($this->environmentPath); + } + + return $app; + } +} diff --git a/tests/Testing/Console/TestCommandTest.php b/tests/Testing/Console/TestCommandTest.php index 3f55327532..3dfb001ddb 100644 --- a/tests/Testing/Console/TestCommandTest.php +++ b/tests/Testing/Console/TestCommandTest.php @@ -6,6 +6,7 @@ use FilesystemIterator; use Hypervel\Support\Env; +use Hypervel\Testbench\Foundation\Console\TestCommand as PackageTestCommand; use Hypervel\Testbench\TestCase; use Hypervel\Testing\Console\TestCommand; use Hypervel\Testing\Coverage; @@ -13,10 +14,15 @@ use Hypervel\Testing\ParallelTesting; use Hypervel\Testing\Profile\ProfileExtension; use Override; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\TextUI\Configuration\Registry as ConfigurationRegistry; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; +use ReflectionMethod; use RuntimeException; +use Symfony\Component\Console\Application as ConsoleApplication; +use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Tester\CommandTester; use Throwable; @@ -39,7 +45,7 @@ protected function setUp(): void parent::setUp(); - foreach (['phpunit.xml', 'custom-phpunit.xml'] as $file) { + foreach (['phpunit.xml', 'phpunit.dist.xml', 'phpunit.xml.dist', 'custom-phpunit.xml'] as $file) { $path = $this->app->basePath($file); $this->originalConfigurationFiles[$path] = [ is_file($path), @@ -97,9 +103,10 @@ protected function tearDown(): void } #[Test] - public function itInjectsTheProfileExtensionIntoATemporaryConfigurationFile(): void + #[DataProvider('profileConfigurationFiles')] + public function itInjectsTheProfileExtensionIntoATemporaryConfigurationFile(string $file): void { - $this->writePhpunitConfiguration(); + $this->writePhpunitConfiguration(file: $file); $command = new TestCommandHarness(['profile' => true]); $command->setHypervel($this->app); @@ -109,8 +116,8 @@ public function itInjectsTheProfileExtensionIntoATemporaryConfigurationFile(): v $configurationArgument = $this->firstConfigurationArgument($arguments); $configurationFile = substr($configurationArgument, strlen('--configuration=')); - $this->assertNotSame($this->app->basePath('phpunit.xml'), $configurationFile); - $this->assertSame(dirname($this->app->basePath('phpunit.xml')), dirname($configurationFile)); + $this->assertNotSame($this->app->basePath($file), $configurationFile); + $this->assertSame(dirname($this->app->basePath($file)), dirname($configurationFile)); $this->assertFileExists($configurationFile); $this->assertStringContainsString(ProfileExtension::class, (string) file_get_contents($configurationFile)); @@ -126,6 +133,14 @@ public function itInjectsTheProfileExtensionIntoATemporaryConfigurationFile(): v $this->assertFileExists($nextConfigurationFile); } + /** + * Provide configuration filenames for profiling. + */ + public static function profileConfigurationFiles(): array + { + return [['phpunit.xml'], ['phpunit.dist.xml']]; + } + #[Test] public function itRunsProfiledTestsWithRelativeConfigurationPaths(): void { @@ -374,11 +389,173 @@ public function itParsesTheConfiguredParatestConfigurationFile(): void $command = new TestCommandHarness(['configuration' => 'custom-phpunit.xml'], commonArguments: []); $command->setHypervel($this->app); + $configuration = ConfigurationRegistry::get(); $arguments = $command->paratestArgumentsPublic(['--configuration=custom-phpunit.xml']); + $this->assertSame($configuration, ConfigurationRegistry::get()); $this->assertContains('--cache-directory', $arguments); } + #[Test] + #[DataProvider('configurationOptionForms')] + public function itUsesTheSelectedConfigurationForProfilingAndParallelCache(string $commandClass, string $form): void + { + $configurationFile = $this->app->basePath('custom-phpunit.xml'); + file_put_contents($configurationFile, ''); + $configurationOptions = match ($form) { + 'short' => ['-c', $configurationFile], + 'attached' => ["-c{$configurationFile}"], + 'long' => ['--configuration', $configurationFile], + }; + $options = ['--profile', ...$configurationOptions]; + + $command = new $commandClass; + $command->setHypervel($this->app); + (new ConsoleApplication)->addCommand($command); + $command->mergeApplicationDefinition(); + $command->setInput(new ArgvInput(['artisan', $command->getName(), ...$options], $command->getDefinition())); + + $profileConfiguration = $command->phpUnitConfigurationFile(); + $this->assertNotSame($configurationFile, $profileConfiguration); + $this->assertStringContainsString(ProfileExtension::class, (string) file_get_contents($profileConfiguration)); + + foreach (['phpunitArguments', 'paratestArguments'] as $method) { + $arguments = (new ReflectionMethod($command, $method))->invoke($command, $options); + + $this->assertSame( + ["--configuration={$profileConfiguration}"], + array_values(array_filter($arguments, static fn (string $argument): bool => str_starts_with($argument, '--configuration'))), + ); + + foreach ($configurationOptions as $option) { + $this->assertNotContains($option, $arguments); + } + + if ($method === 'paratestArguments') { + $this->assertNotContains('--cache-directory', $arguments); + } + } + } + + /** + * Provide configuration option forms for both test commands. + */ + public static function configurationOptionForms(): array + { + return [ + 'application short' => [TestCommand::class, 'short'], + 'application attached' => [TestCommand::class, 'attached'], + 'application long' => [TestCommand::class, 'long'], + 'package short' => [PackageTestCommand::class, 'short'], + 'package attached' => [PackageTestCommand::class, 'attached'], + 'package long' => [PackageTestCommand::class, 'long'], + ]; + } + + #[Test] + public function itResolvesExplicitConfigurationPathsWithoutRewritingThem(): void + { + $this->writePhpunitConfiguration(file: 'custom-phpunit.xml'); + $configurationFile = $this->app->basePath('custom-phpunit.xml'); + + foreach ([ + './custom-phpunit.xml', + '../' . basename($this->app->basePath()) . '/custom-phpunit.xml', + $configurationFile, + ] as $path) { + $command = new TestCommandHarness(['configuration' => $path]); + $command->setHypervel($this->app); + + $this->assertSame($configurationFile, realpath($command->phpUnitConfigurationFilePublic())); + } + } + + #[Test] + public function itPreservesMissingExplicitConfigurationsForPhpunitToReject(): void + { + $this->writePhpunitConfiguration(); + $configurationFile = $this->app->basePath('missing-phpunit.xml'); + + foreach ([false, true] as $profile) { + $command = new TestCommandHarness(['configuration' => $configurationFile, 'profile' => $profile]); + $command->setHypervel($this->app); + + $this->assertContains("--configuration={$configurationFile}", $command->paratestArgumentsPublic([])); + } + } + + #[Test] + public function itFindsConfigurationWithinAnExplicitDirectory(): void + { + $directory = ParallelTesting::tempDir('TestCommandConfigurationDirectory'); + $this->removeDirectory($directory); + mkdir($directory, 0700, true); + + try { + file_put_contents($directory . '/phpunit.dist.xml', ''); + file_put_contents($directory . '/phpunit.xml.dist', ''); + + $command = new TestCommandHarness(['configuration' => $directory]); + $command->setHypervel($this->app); + + $this->assertSame($directory . '/phpunit.dist.xml', $command->phpUnitConfigurationFilePublic()); + $this->assertNotContains('--cache-directory', $command->paratestArgumentsPublic([])); + } finally { + $this->removeDirectory($directory); + } + } + + #[Test] + public function itFindsDefaultConfigurationsInPhpunitOrderAndPreservesTheirCacheDirectory(): void + { + $files = ['phpunit.xml', 'phpunit.dist.xml', 'phpunit.xml.dist']; + + foreach ($files as $file) { + file_put_contents($this->app->basePath($file), ''); + } + + $command = new TestCommandHarness(commonArguments: []); + $command->setHypervel($this->app); + + foreach ($files as $file) { + $this->assertSame($this->app->basePath($file), $command->phpUnitConfigurationFilePublic()); + $this->assertNotContains('--cache-directory', $command->paratestArgumentsPublic([])); + unlink($this->app->basePath($file)); + } + + $this->assertSame('./', $command->phpUnitConfigurationFilePublic()); + $this->assertContains('--cache-directory', $command->paratestArgumentsPublic([])); + } + + #[Test] + #[DataProvider('cacheDirectoryArguments')] + public function itPreservesAnExplicitParatestCacheDirectory(array $options): void + { + $this->writePhpunitConfiguration(); + + $command = new TestCommandHarness(commonArguments: []); + $command->setHypervel($this->app); + + $arguments = $command->paratestArgumentsPublic($options); + + foreach ($options as $option) { + $this->assertContains($option, $arguments); + } + + $this->assertNotContains(sys_get_temp_dir() . DIRECTORY_SEPARATOR . '__hypervel_test_cache_directory', $arguments); + } + + /** + * Provide supported cache-directory option forms. + */ + public static function cacheDirectoryArguments(): array + { + return [ + 'equals' => ['options' => ['--cache-directory=.phpunit.cache']], + 'separate value' => ['options' => ['--cache-directory', '.phpunit.cache']], + ]; + } + #[Test] public function itFiltersWithoutTtyFromForwardedArguments(): void { diff --git a/tests/Testing/ParallelRunnerTest.php b/tests/Testing/ParallelRunnerTest.php index 762d273094..920a06973e 100644 --- a/tests/Testing/ParallelRunnerTest.php +++ b/tests/Testing/ParallelRunnerTest.php @@ -14,12 +14,12 @@ use Hypervel\Testing\ParallelRunner; use ParaTest\Options; use ParaTest\RunnerInterface; +use ParaTest\WrapperRunner\ShardDistribution; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\TextUI\Configuration\Registry as ConfigurationRegistry; use ReflectionClass; use ReflectionMethod; use RuntimeException; -use Symfony\Component\Console\Input\ArgvInput; -use Symfony\Component\Console\Input\InputDefinition; use Symfony\Component\Console\Output\BufferedOutput; use Throwable; @@ -102,7 +102,9 @@ public function itResolvesProcessTokensAsStrings(): void ParallelRunner::resolveApplicationUsing(fn () => new Application($this->app->basePath())); + $configuration = ConfigurationRegistry::get(); $runner = new ParallelRunner($this->optionsWithProcesses(2), new BufferedOutput); + $this->assertSame($configuration, ConfigurationRegistry::get()); $method = new ReflectionMethod(ParallelRunner::class, 'forEachProcess'); $method->invoke($runner, function () use (&$tokens): void { @@ -417,17 +419,26 @@ public function itUsesAnOverriddenSetupLoopButOwnsTeardownByAttemptedToken(): vo */ protected function optionsWithProcesses(int $processes): Options { - $inputDefinition = new InputDefinition; - Options::setInputDefinition($inputDefinition); - - return Options::fromConsoleInput( - new ArgvInput([ - 'paratest', - '--configuration=' . dirname(__DIR__, 2) . '/phpunit.xml.dist', - '--runner=' . ParallelRunner::class, - '--processes=' . $processes, - ], $inputDefinition), - dirname(__DIR__, 2), + // Options::fromConsoleInput() replaces PHPUnit's active configuration and + // can break later isolated tests. Reuse the active configuration for this fixture. + // Named arguments expose changes to ParaTest's internal constructor during upgrades. + return new Options( + configuration: ConfigurationRegistry::get(), + phpunit: dirname(__DIR__, 2) . '/vendor/phpunit/phpunit/phpunit', + cwd: dirname(__DIR__, 2), + maxBatchSize: 0, + noTestTokens: false, + passthruPhp: null, + phpunitOptions: [], + processes: $processes, + runner: ParallelRunner::class, + tmpDir: sys_get_temp_dir(), + verbose: false, + functional: false, + currentShard: 0, + totalShards: 0, + shardDistribution: ShardDistribution::Sequential, + shardDistributionSeed: 0, ); } diff --git a/tests/Testing/TerminalDimensionsTest.php b/tests/Testing/TerminalDimensionsTest.php new file mode 100644 index 0000000000..d9c5680a73 --- /dev/null +++ b/tests/Testing/TerminalDimensionsTest.php @@ -0,0 +1,53 @@ +runTestsInCoroutine = $this->providedData()[0]; + + putenv('COLUMNS'); + putenv('LINES'); + unset($_ENV['COLUMNS'], $_ENV['LINES'], $_SERVER['COLUMNS'], $_SERVER['LINES']); + (new ReflectionProperty(Terminal::class, 'width'))->setValue(null, null); + (new ReflectionProperty(Terminal::class, 'height'))->setValue(null, null); + } + + #[DataProvider('coroutineModes')] + public function testConfiguresHeadlessTerminalDimensionsWithoutAnApplication(bool $runTestsInCoroutine): void + { + $this->assertFalse(stream_isatty(STDOUT)); + $this->assertSame($runTestsInCoroutine, Coroutine::getCid() !== -1); + + $this->assertSame([false, false], [getenv('COLUMNS'), getenv('LINES')]); + $this->assertSame([80, 24], [(new Terminal)->getWidth(), (new Terminal)->getHeight()]); + } + + /** + * Provide coroutine-enabled and coroutine-disabled test modes. + */ + public static function coroutineModes(): array + { + return [ + 'framework coroutine' => [true], + 'without framework coroutine' => [false], + ]; + } +}