From cac5f99225e0c5831fc2a893fd33d877ffddc00b Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 24 Oct 2026 07:07:38 +0000
Subject: [PATCH 1/8] fix(testbench): configure headless terminal dimensions
during application boot
Testbench creates applications through its own bootstrap sequence and its
console kernel has no bootstrappers. As a result, package tests missed the
terminal initialization that normal HTTP and console applications already
receive. Programmatic console calls could repeatedly spawn stty inside
Swoole's hooked subprocess wait when no terminal dimensions were exported.
Run ConfigureTerminalDimensions after environment loading and before
configuration, matching the normal kernels. This also initializes console
output before database test traits run commands during setup and teardown.
Preserve dimensions loaded from the application's environment file.
Add isolated regressions that clear inherited dimensions before refreshing
the application and verify both defaults and environment-file values. The
missing-default regression failed before the bootstrap call was added.
Refs #686
---
.../src/Concerns/CreatesApplication.php | 2 +
tests/Testbench/TerminalDimensionsTest.php | 69 +++++++++++++++++++
2 files changed, 71 insertions(+)
create mode 100644 tests/Testbench/TerminalDimensionsTest.php
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/tests/Testbench/TerminalDimensionsTest.php b/tests/Testbench/TerminalDimensionsTest.php
new file mode 100644
index 0000000000..9371a76a5b
--- /dev/null
+++ b/tests/Testbench/TerminalDimensionsTest.php
@@ -0,0 +1,69 @@
+assertFalse(stream_isatty(STDOUT));
+
+ $this->refreshApplication();
+
+ $this->assertSame('80', getenv('COLUMNS'));
+ $this->assertSame('24', getenv('LINES'));
+ }
+
+ public function testApplicationCreationPreservesDimensionsFromItsEnvironmentFile(): 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']);
+
+ $this->refreshApplication();
+
+ $this->assertSame('120', getenv('COLUMNS'));
+ $this->assertSame('40', getenv('LINES'));
+ } 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;
+ }
+}
From a11e9eb2e2024070d7791ba9852fc21d1fd62d41 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 24 Oct 2026 07:07:43 +0000
Subject: [PATCH 2/8] fix(testing): initialize terminal dimensions in each test
process
Reuse the existing headless terminal policy through an application-free
configure() method. Invoke it before test execution enters either the
framework coroutine or a test-managed coroutine, so unit tests that never
boot an application receive the same protection as application tests.
Symfony Process does not reliably forward putenv-only values, so each test
process establishes its own defaults without requiring PHPUnit XML pins.
Keep explicit dimensions and real TTY behavior unchanged. Document why
failed Symfony dimension probes repeat, their exposure to Swoole's child
process wait bug, and the process-wide lifetime of environment defaults.
Add isolated regressions for both coroutine modes, partial dimension
configuration, and a real pseudo-terminal. The unit-test regressions failed
before initialization was added and pass without PHPUnit configuration.
Retain the components suite's forced XML dimensions solely for consistent
console-output assertions and replace the old hang-prevention comment.
Refs #686
Related: #454
---
phpunit.xml.dist | 8 +---
.../Bootstrap/ConfigureTerminalDimensions.php | 17 ++++++-
.../Testing/Concerns/RunTestsInCoroutine.php | 5 ++
.../ConfigureTerminalDimensionsTest.php | 42 ++++++++++++++++
.../configure-terminal-dimensions.php | 11 +++++
tests/Testing/TerminalDimensionsTest.php | 48 +++++++++++++++++++
6 files changed, 123 insertions(+), 8 deletions(-)
create mode 100644 tests/Foundation/Fixtures/configure-terminal-dimensions.php
create mode 100644 tests/Testing/TerminalDimensionsTest.php
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index eefaa1b079..b19a05a37c 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -29,12 +29,8 @@
diff --git a/src/foundation/src/Bootstrap/ConfigureTerminalDimensions.php b/src/foundation/src/Bootstrap/ConfigureTerminalDimensions.php
index 61540c83ff..2e59b5fc66 100644
--- a/src/foundation/src/Bootstrap/ConfigureTerminalDimensions.php
+++ b/src/foundation/src/Bootstrap/ConfigureTerminalDimensions.php
@@ -9,8 +9,10 @@
/**
* 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,6 +20,17 @@ class ConfigureTerminalDimensions
* Configure deterministic dimensions for a headless terminal.
*/
public function bootstrap(Application $app): void
+ {
+ static::configure();
+ }
+
+ /**
+ * Configure deterministic dimensions without requiring an application.
+ *
+ * Defaults are written to the process environment and apply to every
+ * subsequent terminal lookup in the worker.
+ */
+ public static function configure(): void
{
if (defined('STDOUT') && stream_isatty(STDOUT)) {
return;
diff --git a/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php b/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php
index 2d8c0e40a3..50e7fd83cc 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
{
+ // Workers may not inherit putenv-only dimensions from their parent. Configure
+ // each process before tests enter hooked coroutines, including their own run().
+ ConfigureTerminalDimensions::configure();
+
if (Coroutine::getCid() !== -1 || ! $this->runsTestsInCoroutine()) {
return parent::invokeTestMethod($methodName, $testArguments);
}
diff --git a/tests/Foundation/Bootstrap/ConfigureTerminalDimensionsTest.php b/tests/Foundation/Bootstrap/ConfigureTerminalDimensionsTest.php
index 7e36c92b96..12495544df 100644
--- a/tests/Foundation/Bootstrap/ConfigureTerminalDimensionsTest.php
+++ b/tests/Foundation/Bootstrap/ConfigureTerminalDimensionsTest.php
@@ -10,11 +10,16 @@
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\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
{
@@ -45,6 +50,43 @@ public function testConfiguresTerminalDimensionsWhenTheyAreAbsent(): void
$this->assertSame('24', getenv('LINES'));
}
+ #[RunInSeparateProcess]
+ #[DataProvider('partialDimensions')]
+ public function testPreservesEachDimensionIndependently(string $preset, string $missing, string $expectedDefault): void
+ {
+ putenv("{$preset}=120");
+ putenv($missing);
+
+ (new ConfigureTerminalDimensions)->bootstrap(new Application);
+
+ $this->assertSame('120', getenv($preset));
+ $this->assertSame($expectedDefault, getenv($missing));
+ }
+
+ /**
+ * Provide each individually configured terminal dimension.
+ */
+ public static function partialDimensions(): array
+ {
+ return [
+ 'width' => ['COLUMNS', 'LINES', '24'],
+ 'height' => ['LINES', 'COLUMNS', '80'],
+ ];
+ }
+
+ 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], json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR));
+ }
+
public function testKernelsIncludeTerminalDimensionsBootstrapper(): void
{
foreach ([HttpKernel::class, ConsoleKernel::class] as $kernel) {
diff --git a/tests/Foundation/Fixtures/configure-terminal-dimensions.php b/tests/Foundation/Fixtures/configure-terminal-dimensions.php
new file mode 100644
index 0000000000..95db69ebf1
--- /dev/null
+++ b/tests/Foundation/Fixtures/configure-terminal-dimensions.php
@@ -0,0 +1,11 @@
+runTestsInCoroutine = $this->providedData()[0];
+
+ putenv('COLUMNS');
+ putenv('LINES');
+ unset($_ENV['COLUMNS'], $_ENV['LINES'], $_SERVER['COLUMNS'], $_SERVER['LINES']);
+ }
+
+ #[DataProvider('coroutineModes')]
+ public function testConfiguresHeadlessTerminalDimensionsWithoutAnApplication(bool $runTestsInCoroutine): void
+ {
+ $this->assertFalse(stream_isatty(STDOUT));
+ $this->assertSame($runTestsInCoroutine, Coroutine::getCid() !== -1);
+
+ $this->assertSame(['80', '24'], [getenv('COLUMNS'), getenv('LINES')]);
+ }
+
+ /**
+ * Provide coroutine-enabled and coroutine-disabled test modes.
+ */
+ public static function coroutineModes(): array
+ {
+ return [
+ 'framework coroutine' => [true],
+ 'without framework coroutine' => [false],
+ ];
+ }
+}
From 404dff1e4eb75763941f45ad297b3f20da443163 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 24 Oct 2026 07:40:54 +0000
Subject: [PATCH 3/8] fix(testing): clear framework terminal defaults after
each test
A unit test can initialize COLUMNS and LINES before an application test
loads its environment file. Dotenv treats those process values as external,
so the later application's configured dimensions are ignored. This makes
console formatting depend on test order even with normal framework state
cleanup enabled.
Track only the two variable names populated by ConfigureTerminalDimensions
and remove them through the existing after-each-test subscriber. Values
provided before initialization, including shell and PHPUnit XML settings,
are never owned or removed. Clear the ownership set after cleanup so a
subsequent test's explicit dimensions survive later cleanup passes.
Add an isolated regression that failed before the fix and verifies both
owned-default removal and explicit-value preservation. Also verify the
unit-before-application reproduction now honors the application's env-file
width without terminal settings in PHPUnit XML.
Refs #686
---
.../Bootstrap/ConfigureTerminalDimensions.php | 22 +++++++++++
.../src/PHPUnit/AfterEachTestSubscriber.php | 1 +
.../PHPUnit/AfterEachTestSubscriberTest.php | 37 +++++++++++++++++++
3 files changed, 60 insertions(+)
diff --git a/src/foundation/src/Bootstrap/ConfigureTerminalDimensions.php b/src/foundation/src/Bootstrap/ConfigureTerminalDimensions.php
index 2e59b5fc66..5710ce08ea 100644
--- a/src/foundation/src/Bootstrap/ConfigureTerminalDimensions.php
+++ b/src/foundation/src/Bootstrap/ConfigureTerminalDimensions.php
@@ -16,6 +16,13 @@
*/
class ConfigureTerminalDimensions
{
+ /**
+ * The environment variables populated with framework defaults.
+ *
+ * @var array
+ */
+ protected static array $configuredVariables = [];
+
/**
* Configure deterministic dimensions for a headless terminal.
*/
@@ -38,10 +45,25 @@ public static function configure(): void
if (getenv('COLUMNS') === false) {
putenv('COLUMNS=80');
+ static::$configuredVariables['COLUMNS'] = true;
}
if (getenv('LINES') === false) {
putenv('LINES=24');
+ static::$configuredVariables['LINES'] = true;
}
}
+
+ /**
+ * Flush all static state.
+ */
+ public static function flushState(): void
+ {
+ // Remove only defaults we supplied; explicit shell and XML dimensions must survive.
+ foreach (array_keys(static::$configuredVariables) as $name) {
+ putenv($name);
+ }
+
+ static::$configuredVariables = [];
+ }
}
diff --git a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php
index d060f12e87..1c7d24664a 100644
--- a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php
+++ b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php
@@ -192,6 +192,7 @@ protected function flushFrameworkState(): void
\Hypervel\Filesystem\FilesystemAdapter::flushState();
\Hypervel\Filesystem\FilesystemManager::flushState();
\Hypervel\Foundation\Application::flushState();
+ \Hypervel\Foundation\Bootstrap\ConfigureTerminalDimensions::flushState();
\Hypervel\Foundation\Bootstrap\LoadConfiguration::flushState();
\Hypervel\Foundation\Bootstrap\RegisterProviders::flushState();
\Hypervel\Foundation\Console\AboutCommand::flushState();
diff --git a/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php b/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php
index bf03c53280..fc3a12a595 100644
--- a/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php
+++ b/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php
@@ -19,6 +19,7 @@
use Hypervel\Database\SessionConfigurator;
use Hypervel\Encryption\Commands\KeyGenerateCommand;
use Hypervel\Events\Dispatcher;
+use Hypervel\Foundation\Bootstrap\ConfigureTerminalDimensions;
use Hypervel\Foundation\Testing\DatabaseConnectionResolver;
use Hypervel\Http\Client\Factory as HttpFactory;
use Hypervel\Http\Client\PendingRequest;
@@ -55,6 +56,7 @@
use Mockery\Exception\InvalidCountException;
use Override;
use PDO;
+use PHPUnit\Framework\Attributes\RunInSeparateProcess;
use ReflectionClass;
use ReflectionProperty;
use RuntimeException;
@@ -328,6 +330,41 @@ public function flushFrameworkStateForTest(): void
}
}
+ #[RunInSeparateProcess]
+ public function testFrameworkCleanupRemovesOnlyTerminalDefaults(): void
+ {
+ $subscriber = new class extends AfterEachTestSubscriber {
+ /**
+ * Flush framework state for the test.
+ */
+ public function flushFrameworkStateForTest(): void
+ {
+ $this->flushFrameworkState();
+ }
+ };
+
+ // Forget defaults the test runner supplied before this test body.
+ ConfigureTerminalDimensions::flushState();
+
+ putenv('COLUMNS=200');
+ putenv('LINES');
+ unset($_ENV['COLUMNS'], $_ENV['LINES'], $_SERVER['COLUMNS'], $_SERVER['LINES']);
+
+ ConfigureTerminalDimensions::configure();
+
+ $this->assertSame('24', getenv('LINES'));
+
+ $subscriber->flushFrameworkStateForTest();
+
+ $this->assertFalse(getenv('LINES'));
+ $this->assertSame('200', getenv('COLUMNS'));
+
+ putenv('LINES=40');
+ $subscriber->flushFrameworkStateForTest();
+
+ $this->assertSame('40', getenv('LINES'));
+ }
+
public function testFrameworkCleanupFlushesNestedSetMetadata(): void
{
$classes = new ReflectionProperty(NestedSet::class, 'nodeClasses');
From 4d447c6b7b8ab826c0338d158c918215ff4e9862 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 24 Oct 2026 08:36:41 +0000
Subject: [PATCH 4/8] fix(console): keep headless terminal defaults out of the
environment
Writing fallback COLUMNS and LINES into the process environment makes
immutable dotenv loading mistake framework defaults for explicit settings.
Tracking variable names for cleanup also removes values restored by test
environment helpers. Those values share a channel, so cleanup cannot
reliably infer which code owns them.
Seed only the missing width and height entries in Symfony Terminal's
process-local cache when stdout is headless. Symfony continues to prefer
explicit environment values on every lookup, including values loaded after
initialization. Existing cached dimensions and real terminals are left
alone. No environment writes, ownership state, or test cleanup are needed.
Use reflection at this single boundary because Symfony exposes no cache
setter. Document the reason and test both cache entries before accessing
terminal dimensions, so Symfony's native width fallback cannot conceal a
failed probe or a dependency change. Remove the obsolete cleanup hook and
its test, and retain per-process initialization for Testbench and unit tests.
Cover missing, partial, explicit, temporarily restored, and later dotenv
values, real PTY behavior, both coroutine modes, and initialization before
provider registration. Terminal regressions pass without PHPUnit XML or
the cleanup extension. Full composer fix passed, including formatting,
source and type-fixture analysis, framework tests, Testbench package mode,
and both dogfood suites. Focused checks passed after the final test-only
refinements.
Refs #686
---
.../Bootstrap/ConfigureTerminalDimensions.php | 41 +++--------
.../Testing/Concerns/RunTestsInCoroutine.php | 4 +-
.../src/PHPUnit/AfterEachTestSubscriber.php | 1 -
.../ConfigureTerminalDimensionsTest.php | 70 +++++++++++++++----
.../configure-terminal-dimensions.php | 9 ++-
tests/Testbench/TerminalDimensionsTest.php | 21 +++++-
.../PHPUnit/AfterEachTestSubscriberTest.php | 37 ----------
tests/Testing/TerminalDimensionsTest.php | 7 +-
8 files changed, 102 insertions(+), 88 deletions(-)
diff --git a/src/foundation/src/Bootstrap/ConfigureTerminalDimensions.php b/src/foundation/src/Bootstrap/ConfigureTerminalDimensions.php
index 5710ce08ea..53d52f9a7f 100644
--- a/src/foundation/src/Bootstrap/ConfigureTerminalDimensions.php
+++ b/src/foundation/src/Bootstrap/ConfigureTerminalDimensions.php
@@ -5,6 +5,8 @@
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.
@@ -16,13 +18,6 @@
*/
class ConfigureTerminalDimensions
{
- /**
- * The environment variables populated with framework defaults.
- *
- * @var array
- */
- protected static array $configuredVariables = [];
-
/**
* Configure deterministic dimensions for a headless terminal.
*/
@@ -32,10 +27,7 @@ public function bootstrap(Application $app): void
}
/**
- * Configure deterministic dimensions without requiring an application.
- *
- * Defaults are written to the process environment and apply to every
- * subsequent terminal lookup in the worker.
+ * Configure headless fallback dimensions without modifying the environment.
*/
public static function configure(): void
{
@@ -43,27 +35,14 @@ public static function configure(): void
return;
}
- if (getenv('COLUMNS') === false) {
- putenv('COLUMNS=80');
- static::$configuredVariables['COLUMNS'] = true;
- }
+ // 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');
- static::$configuredVariables['LINES'] = true;
+ if ($property->getValue() === null) {
+ $property->setValue(null, $default);
+ }
}
}
-
- /**
- * Flush all static state.
- */
- public static function flushState(): void
- {
- // Remove only defaults we supplied; explicit shell and XML dimensions must survive.
- foreach (array_keys(static::$configuredVariables) as $name) {
- putenv($name);
- }
-
- static::$configuredVariables = [];
- }
}
diff --git a/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php b/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php
index 50e7fd83cc..eb38e870b6 100644
--- a/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php
+++ b/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php
@@ -46,8 +46,8 @@ trait RunTestsInCoroutine
*/
protected function invokeTestMethod(string $methodName, array $testArguments): mixed
{
- // Workers may not inherit putenv-only dimensions from their parent. Configure
- // each process before tests enter hooked coroutines, including their own run().
+ // Initialize each test process before it enters hooked coroutines,
+ // including tests that start their own run().
ConfigureTerminalDimensions::configure();
if (Coroutine::getCid() !== -1 || ! $this->runsTestsInCoroutine()) {
diff --git a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php
index 1c7d24664a..d060f12e87 100644
--- a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php
+++ b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php
@@ -192,7 +192,6 @@ protected function flushFrameworkState(): void
\Hypervel\Filesystem\FilesystemAdapter::flushState();
\Hypervel\Filesystem\FilesystemManager::flushState();
\Hypervel\Foundation\Application::flushState();
- \Hypervel\Foundation\Bootstrap\ConfigureTerminalDimensions::flushState();
\Hypervel\Foundation\Bootstrap\LoadConfiguration::flushState();
\Hypervel\Foundation\Bootstrap\RegisterProviders::flushState();
\Hypervel\Foundation\Console\AboutCommand::flushState();
diff --git a/tests/Foundation/Bootstrap/ConfigureTerminalDimensionsTest.php b/tests/Foundation/Bootstrap/ConfigureTerminalDimensionsTest.php
index 12495544df..22f01ec0eb 100644
--- a/tests/Foundation/Bootstrap/ConfigureTerminalDimensionsTest.php
+++ b/tests/Foundation/Bootstrap/ConfigureTerminalDimensionsTest.php
@@ -6,13 +6,14 @@
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
@@ -28,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]
@@ -44,23 +48,32 @@ 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());
}
#[RunInSeparateProcess]
#[DataProvider('partialDimensions')]
- public function testPreservesEachDimensionIndependently(string $preset, string $missing, string $expectedDefault): void
+ public function testPreservesEachDimensionIndependently(string $preset, string $missing, int $expectedWidth, int $expectedHeight): void
{
putenv("{$preset}=120");
putenv($missing);
+ $this->resetTerminalDimensions();
(new ConfigureTerminalDimensions)->bootstrap(new Application);
$this->assertSame('120', getenv($preset));
- $this->assertSame($expectedDefault, getenv($missing));
+ $this->assertFalse(getenv($missing));
+ $this->assertSame($expectedWidth, (new Terminal)->getWidth());
+ $this->assertSame($expectedHeight, (new Terminal)->getHeight());
}
/**
@@ -69,11 +82,35 @@ public function testPreservesEachDimensionIndependently(string $preset, string $
public static function partialDimensions(): array
{
return [
- 'width' => ['COLUMNS', 'LINES', '24'],
- 'height' => ['LINES', 'COLUMNS', '80'],
+ '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 testLeavesRealTerminalDimensionsUnchanged(): void
{
$process = new Process(
@@ -84,7 +121,7 @@ public function testLeavesRealTerminalDimensionsUnchanged(): void
$process->setPty(true);
$process->mustRun();
- $this->assertSame([true, false, false], json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR));
+ $this->assertSame([true, false, false, null, null], json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR));
}
public function testKernelsIncludeTerminalDimensionsBootstrapper(): void
@@ -97,16 +134,16 @@ public function testKernelsIncludeTerminalDimensionsBootstrapper(): void
}
}
- public function testTerminalDimensionsAreConfiguredAfterEnvironmentVariablesAreLoaded(): void
+ 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);
}
}
@@ -119,4 +156,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
index 95db69ebf1..6706782296 100644
--- a/tests/Foundation/Fixtures/configure-terminal-dimensions.php
+++ b/tests/Foundation/Fixtures/configure-terminal-dimensions.php
@@ -3,9 +3,16 @@
declare(strict_types=1);
use Hypervel\Foundation\Bootstrap\ConfigureTerminalDimensions;
+use Symfony\Component\Console\Terminal;
require __DIR__ . '/../../../vendor/autoload.php';
ConfigureTerminalDimensions::configure();
-echo json_encode([stream_isatty(STDOUT), getenv('COLUMNS'), getenv('LINES')], JSON_THROW_ON_ERROR);
+echo json_encode([
+ stream_isatty(STDOUT),
+ getenv('COLUMNS'),
+ getenv('LINES'),
+ (new ReflectionProperty(Terminal::class, 'width'))->getValue(),
+ (new ReflectionProperty(Terminal::class, 'height'))->getValue(),
+], JSON_THROW_ON_ERROR);
diff --git a/tests/Testbench/TerminalDimensionsTest.php b/tests/Testbench/TerminalDimensionsTest.php
index 9371a76a5b..2ff093b9e1 100644
--- a/tests/Testbench/TerminalDimensionsTest.php
+++ b/tests/Testbench/TerminalDimensionsTest.php
@@ -6,10 +6,14 @@
use Hypervel\Contracts\Foundation\Application as ApplicationContract;
use Hypervel\Filesystem\Filesystem;
+use Hypervel\Foundation\Bootstrap\ConfigureTerminalDimensions;
use Hypervel\Support\DotenvManager;
+use Hypervel\Support\Env;
use Hypervel\Testbench\TestCase;
use Hypervel\Testing\ParallelTesting;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
+use ReflectionProperty;
+use Symfony\Component\Console\Terminal;
#[RunTestsInSeparateProcesses]
class TerminalDimensionsTest extends TestCase
@@ -21,16 +25,20 @@ public function testApplicationCreationConfiguresHeadlessTerminalDimensions(): v
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);
$this->assertFalse(stream_isatty(STDOUT));
$this->refreshApplication();
- $this->assertSame('80', getenv('COLUMNS'));
- $this->assertSame('24', getenv('LINES'));
+ $this->assertFalse(getenv('COLUMNS'));
+ $this->assertFalse(getenv('LINES'));
+ $this->assertSame(80, (new Terminal)->getWidth());
+ $this->assertSame(24, (new Terminal)->getHeight());
}
- public function testApplicationCreationPreservesDimensionsFromItsEnvironmentFile(): void
+ public function testEnvironmentFileOverridesEarlierTerminalDefaults(): void
{
$this->environmentPath = ParallelTesting::tempDir('TestbenchTerminalDimensions');
$files = new Filesystem;
@@ -44,10 +52,17 @@ public function testApplicationCreationPreservesDimensionsFromItsEnvironmentFile
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);
}
diff --git a/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php b/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php
index fc3a12a595..bf03c53280 100644
--- a/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php
+++ b/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php
@@ -19,7 +19,6 @@
use Hypervel\Database\SessionConfigurator;
use Hypervel\Encryption\Commands\KeyGenerateCommand;
use Hypervel\Events\Dispatcher;
-use Hypervel\Foundation\Bootstrap\ConfigureTerminalDimensions;
use Hypervel\Foundation\Testing\DatabaseConnectionResolver;
use Hypervel\Http\Client\Factory as HttpFactory;
use Hypervel\Http\Client\PendingRequest;
@@ -56,7 +55,6 @@
use Mockery\Exception\InvalidCountException;
use Override;
use PDO;
-use PHPUnit\Framework\Attributes\RunInSeparateProcess;
use ReflectionClass;
use ReflectionProperty;
use RuntimeException;
@@ -330,41 +328,6 @@ public function flushFrameworkStateForTest(): void
}
}
- #[RunInSeparateProcess]
- public function testFrameworkCleanupRemovesOnlyTerminalDefaults(): void
- {
- $subscriber = new class extends AfterEachTestSubscriber {
- /**
- * Flush framework state for the test.
- */
- public function flushFrameworkStateForTest(): void
- {
- $this->flushFrameworkState();
- }
- };
-
- // Forget defaults the test runner supplied before this test body.
- ConfigureTerminalDimensions::flushState();
-
- putenv('COLUMNS=200');
- putenv('LINES');
- unset($_ENV['COLUMNS'], $_ENV['LINES'], $_SERVER['COLUMNS'], $_SERVER['LINES']);
-
- ConfigureTerminalDimensions::configure();
-
- $this->assertSame('24', getenv('LINES'));
-
- $subscriber->flushFrameworkStateForTest();
-
- $this->assertFalse(getenv('LINES'));
- $this->assertSame('200', getenv('COLUMNS'));
-
- putenv('LINES=40');
- $subscriber->flushFrameworkStateForTest();
-
- $this->assertSame('40', getenv('LINES'));
- }
-
public function testFrameworkCleanupFlushesNestedSetMetadata(): void
{
$classes = new ReflectionProperty(NestedSet::class, 'nodeClasses');
diff --git a/tests/Testing/TerminalDimensionsTest.php b/tests/Testing/TerminalDimensionsTest.php
index 72a891896a..d9c5680a73 100644
--- a/tests/Testing/TerminalDimensionsTest.php
+++ b/tests/Testing/TerminalDimensionsTest.php
@@ -7,7 +7,9 @@
use Hypervel\Testing\UnitTestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
+use ReflectionProperty;
use Swoole\Coroutine;
+use Symfony\Component\Console\Terminal;
#[RunTestsInSeparateProcesses]
class TerminalDimensionsTest extends UnitTestCase
@@ -24,6 +26,8 @@ protected function setUp(): void
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')]
@@ -32,7 +36,8 @@ public function testConfiguresHeadlessTerminalDimensionsWithoutAnApplication(boo
$this->assertFalse(stream_isatty(STDOUT));
$this->assertSame($runTestsInCoroutine, Coroutine::getCid() !== -1);
- $this->assertSame(['80', '24'], [getenv('COLUMNS'), getenv('LINES')]);
+ $this->assertSame([false, false], [getenv('COLUMNS'), getenv('LINES')]);
+ $this->assertSame([80, 24], [(new Terminal)->getWidth(), (new Terminal)->getHeight()]);
}
/**
From 28d8468672aa7c2cd5604641f5bb123ee4015781 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 24 Oct 2026 08:42:29 +0000
Subject: [PATCH 5/8] test(console): consolidate terminal bootstrap ordering
coverage
The provider-order regression already verifies that both kernels include
the terminal bootstrapper before comparing its position with provider
registration. Remove the separate inclusion test, which checks the same
condition without covering another behavior.
The focused bootstrap suite passes with 7 tests and 37 assertions.
Targeted formatting and git diff --check are clean.
---
.../Bootstrap/ConfigureTerminalDimensionsTest.php | 10 ----------
1 file changed, 10 deletions(-)
diff --git a/tests/Foundation/Bootstrap/ConfigureTerminalDimensionsTest.php b/tests/Foundation/Bootstrap/ConfigureTerminalDimensionsTest.php
index 22f01ec0eb..ac308abbf6 100644
--- a/tests/Foundation/Bootstrap/ConfigureTerminalDimensionsTest.php
+++ b/tests/Foundation/Bootstrap/ConfigureTerminalDimensionsTest.php
@@ -124,16 +124,6 @@ public function testLeavesRealTerminalDimensionsUnchanged(): void
$this->assertSame([true, false, false, null, null], json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR));
}
- public function testKernelsIncludeTerminalDimensionsBootstrapper(): void
- {
- foreach ([HttpKernel::class, ConsoleKernel::class] as $kernel) {
- $this->assertContains(
- ConfigureTerminalDimensions::class,
- $this->getBootstrappers($kernel),
- );
- }
- }
-
public function testTerminalDimensionsAreConfiguredBeforeProvidersAreRegistered(): void
{
foreach ([HttpKernel::class, ConsoleKernel::class] as $kernel) {
From 86978be304c6eaef42d394c8bf7035b5a31490c2 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 24 Oct 2026 09:31:16 +0000
Subject: [PATCH 6/8] fix(testing): preserve PHPUnit state when preparing test
commands
Building parallel command arguments called ParaTest's Options factory only
to inspect its cache setting. That factory replaces PHPUnit's active
configuration, causing later isolated tests to load the command fixture's
bootstrap instead of the suite bootstrap. Test order determined whether
the temporary autoloader still existed, producing intermittent CI failures.
Inspect the CLI cache option and selected XML directly, leaving runner
initialization to the child process. Preserve explicit cache paths and
explain the configuration side effect beside the argument-building code.
Use PHPUnit's default configuration filename order, search explicit
configuration directories, preserve relative and absolute paths, and
forward missing explicit paths for PHPUnit to reject instead of silently
running a different configuration.
Make -c the configuration shortcut for both application and package tests.
Remove the original short or long selector before forwarding so it cannot
override the generated profile configuration. Remove Testbench's unused
custom-argument flag and document that difference in the source, README,
command reference, and porting guide.
Regression coverage preserves PHPUnit configuration identity and exercises
cache options, discovery, missing paths, profiling, and real Symfony input
for both commands. The relevant failures were reproduced before fixing
them. Validation: command tests (31 tests, 165 assertions), Testing suite
(696 tests, 3017 assertions), Testbench package mode (647 tests, 2103
assertions, 3 skips), PHPStan source/type fixtures, and changed-file style
checks all pass. The isolated-test CI failure sequence also passes.
---
src/docs/porting-from-laravel.md | 2 +
src/docs/testbench.md | 2 +-
src/testbench/README.md | 2 +
.../src/Foundation/Console/TestCommand.php | 6 +-
src/testing/src/Console/TestCommand.php | 2 +-
src/testing/src/Console/TestCommandBase.php | 49 ++++-
tests/Testing/Console/TestCommandTest.php | 187 +++++++++++++++++-
7 files changed, 231 insertions(+), 19 deletions(-)
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/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/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/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
{
From fe6053a58c4b5b2bfcd723de9f14a56dc660d886 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 24 Oct 2026 09:31:37 +0000
Subject: [PATCH 7/8] test(testing): preserve active configuration in parallel
runner fixtures
Construct ParaTest Options directly with PHPUnit's active configuration
instead of calling the CLI factory, which replaces the process-wide
configuration and emits a runner-configuration event during a test.
Keep the requested process count and fixture settings explicit through
named constructor arguments. Explain both the avoided global side effect
and the dependency on ParaTest's internal constructor beside the fixture.
Add an identity assertion to the existing process-token test so returning
to the mutating factory fails immediately. The assertion failed before
the change. Validation: all 12 parallel-runner tests (48 assertions) and
the affected Testing and Testbench package-mode suites pass; style and
static analysis checks are clean.
---
tests/Testing/ParallelRunnerTest.php | 37 ++++++++++++++++++----------
1 file changed, 24 insertions(+), 13 deletions(-)
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,
);
}
From 191c811473d42eb890db3e1eae5f44e5d0b9a850 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 24 Oct 2026 09:31:37 +0000
Subject: [PATCH 8/8] test: report isolated child startup errors on stderr
PHPUnit sets display_errors to stderr before booting isolated children,
but the suite XML changed it back to on, which sends errors to stdout.
PHPUnit does not include that stdout in an unexpected-child-exit error.
With error logging disabled in CI, a missing bootstrap therefore produced
only "Test was run in child process and ended unexpectedly".
Keep displayed errors on stderr and explain why in the suite configuration.
This exposes the actual startup error without changing terminal settings
or adding custom child-process reporting code.
Verified with the exact failing CI image: the old source and a five-test
sequence produce two generic child-exit errors with display_errors=on;
changing only this setting exposes both missing-autoloader fatal messages.
The complete fixed sequence passes. The full four-worker framework suite
with stderr enabled passes: 52899 tests, 188711 assertions, 4180 skips.
---
phpunit.xml.dist | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index b19a05a37c..758d559522 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -24,7 +24,8 @@
-
+
+