From ef469cf830e56f75585bf0cefe6f3246d9f595f7 Mon Sep 17 00:00:00 2001 From: Meacue Date: Fri, 28 Aug 2026 15:42:43 +0500 Subject: [PATCH] fix(report): let a bare `--log-html` fall back to the default report path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tests/README.md` documents the flag with no path as writing `runtime/report/index.html`, but the option was registered VALUE_REQUIRED, so Symfony rejected the bare form before the run even started. Register it VALUE_OPTIONAL and normalize the valueless form in `Run::initialize()`, which runs before `Base::execute()` snapshots the options for config hydration. Symfony reports null both for an absent and a valueless option, so presence is read from the raw parameters — the technique `Base::resolveColorMode()` already uses — and mapped to `HtmlPlugin::DEFAULT_PATH`; a directory destination then produces the documented `runtime/report/index.html`. The acceptance tests run the command end-to-end down to the files on disk. Their sandbox config swaps the default reporter for a fresh `HtmlPlugin::inert()`: the application defaults hold one shared instance per process, and its single-shot guard is already spent by the outer run executing the tests. Refs #310 Assisted-By: Claude Fable 5 --- bridge/symfony-console/src/Command/Run.php | 21 ++- .../tests/Acceptance/RunCommandTest.php | 161 ++++++++++++++++++ .../tests/Stub/Run/PassingCase.php | 21 +++ core/Output/Html/HtmlPlugin.php | 3 +- skills/testo-run-tests/SKILL.md | 3 +- 5 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 bridge/symfony-console/tests/Acceptance/RunCommandTest.php create mode 100644 bridge/symfony-console/tests/Stub/Run/PassingCase.php diff --git a/bridge/symfony-console/src/Command/Run.php b/bridge/symfony-console/src/Command/Run.php index 586bb475..2464406b 100644 --- a/bridge/symfony-console/src/Command/Run.php +++ b/bridge/symfony-console/src/Command/Run.php @@ -9,6 +9,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Testo\Output\Html\HtmlPlugin; use Testo\Output\Json\JsonPlugin; use Testo\Output\Teamcity\TeamcityPlugin; use Testo\Output\Terminal\TerminalPlugin; @@ -154,9 +155,10 @@ public function configure(): void $this->addOption( 'log-html', null, - InputOption::VALUE_REQUIRED, + InputOption::VALUE_OPTIONAL, 'Write a self-contained HTML report. A path ending in ".html" produces that single file; ' . 'anything else is a directory to fill with index.html and its assets. ' + . 'Without a value the report goes to ' . HtmlPlugin::DEFAULT_PATH . '. ' . 'The report opens over file:// with no server.', ); // $this->addOption( @@ -219,4 +221,21 @@ public function __invoke( ? Command::SUCCESS : Command::FAILURE; } + + /** + * Exists because the option definition cannot express "a bare `--log-html` falls back to the default + * path": a VALUE_OPTIONAL option reports null both when absent and when passed without a value, and a + * declared default would fire for the absent case too — so the two nulls are told apart against the + * raw parameters instead. Lives in `initialize()` and not in `__invoke()` with the other flags + * because {@see Base::execute()} snapshots the options for config hydration between the two — any + * later, and the resolved path would never reach the reporter. + */ + #[\Override] + protected function initialize(InputInterface $input, OutputInterface $output): void + { + parent::initialize($input, $output); + + $input->getOption('log-html') === null && $input->hasParameterOption('--log-html', true) + and $input->setOption('log-html', HtmlPlugin::DEFAULT_PATH); + } } diff --git a/bridge/symfony-console/tests/Acceptance/RunCommandTest.php b/bridge/symfony-console/tests/Acceptance/RunCommandTest.php new file mode 100644 index 00000000..1f6ca08b --- /dev/null +++ b/bridge/symfony-console/tests/Acceptance/RunCommandTest.php @@ -0,0 +1,161 @@ +run(['--log-html' => null]); + + Assert::same( + $tester->getStatusCode(), + Command::SUCCESS, + 'a valueless --log-html must be accepted, not rejected by the parser; output: ' . $tester->getDisplay(), + ); + Assert::same( + $tester->getInput()->getOption('log-html'), + HtmlPlugin::DEFAULT_PATH, + 'a bare --log-html must fall back to the default report location', + ); + Assert::true( + \is_file($this->sandbox->path('runtime/report/index.html')), + 'a bare --log-html must write the report to runtime/report/index.html; output: ' . $tester->getDisplay(), + ); + } + + public function logHtmlWithAnExplicitPathWritesThatSingleFile(): void + { + $tester = $this->run(['--log-html' => 'build/report.html']); + + Assert::same( + $tester->getStatusCode(), + Command::SUCCESS, + 'the stub suite must pass; output: ' . $tester->getDisplay(), + ); + Assert::true( + \is_file($this->sandbox->path('build/report.html')), + 'an explicit .html path must produce that single file, untouched by the bare-flag fallback; output: ' + . $tester->getDisplay(), + ); + Assert::false( + \is_dir($this->sandbox->path('runtime/report')), + 'the default location must stay untouched when a path is given', + ); + } + + public function withoutTheFlagNothingIsResolvedAndNoReportIsWritten(): void + { + $tester = $this->run(); + + Assert::same( + $tester->getStatusCode(), + Command::SUCCESS, + 'the stub suite must pass; output: ' . $tester->getDisplay(), + ); + Assert::null( + $tester->getInput()->getOption('log-html'), + 'an absent flag must stay absent — the fallback belongs to the bare flag only', + ); + Assert::false( + \is_dir($this->sandbox->path('runtime')), + 'a run without the flag must leave no report behind', + ); + } + + #[BeforeTest] + public function setUp(): void + { + $this->sandbox = Sandbox::create(); + $this->sandbox->writeFile('testo.php', self::configPointingAtTheStubSuite()); + } + + #[AfterTest] + public function tearDown(): void + { + $this->sandbox->destroy(); + } + + /** + * The smallest config a nested run needs: no sources, one suite pointing at this module's stub case, + * and a fresh inert reporter in place of the process-wide default (see the class docblock). + * The stub directory is embedded as an absolute path — the sandbox CWD is elsewhere. + */ + private static function configPointingAtTheStubSuite(): string + { + $stub = \var_export(\dirname(__DIR__) . '/Stub/Run', true); + + return <<with(HtmlPlugin::inert()), + ); + PHP; + } + + /** + * Run the `run` command against the sandbox config in non-interactive mode. + * + * Pass CLI flags as the standard CommandTester input array; a bare flag is a key with a null value, + * e.g. `['--log-html' => null]`. + * + * @param array $input + */ + private function run(array $input = []): CommandTester + { + $tester = new CommandTester(new Run()); + $tester->execute( + $input, + ['interactive' => false, 'capture_stderr_separately' => false], + ); + + return $tester; + } +} diff --git a/bridge/symfony-console/tests/Stub/Run/PassingCase.php b/bridge/symfony-console/tests/Stub/Run/PassingCase.php new file mode 100644 index 00000000..afafb38b --- /dev/null +++ b/bridge/symfony-console/tests/Stub/Run/PassingCase.php @@ -0,0 +1,21 @@ +` and - * `--log-report=` flags have something to activate without any change to `testo.php`. An instance + * `--log-report=` flags have something to activate without any change to `testo.php`; a bare + * `--log-html` activates it at {@see self::DEFAULT_PATH}. An instance * configured in code owns its slots and ignores the flags; only an instance with no destination of its * own reads them, which is how the inert default gets activated. Every destination a run collects — from * configured plugins and from flags — feeds a single {@see HtmlReportSink}: the document is built once diff --git a/skills/testo-run-tests/SKILL.md b/skills/testo-run-tests/SKILL.md index ab25570f..e7509e9c 100644 --- a/skills/testo-run-tests/SKILL.md +++ b/skills/testo-run-tests/SKILL.md @@ -103,7 +103,8 @@ over-narrow filter — widen it (a typo in `--filter`, a `--path` that matches n - Coverage (`--coverage`, `--coverage-clover=`, `--coverage-level=`, …) — see the `testo-coverage` skill. -- `--log-junit=`, `--log-html=` (a `.html` path → single file, anything else → directory), +- `--log-junit=`, `--log-html=` (a `.html` path → single file, anything else → directory; + bare `--log-html` → `runtime/report`), `--log-report=` (the full run as a versioned JSON document — the data behind the HTML), `--teamcity` — reports for CI and IDEs, not for agent parsing. - `--config=path/to/testo.php` when the config is not at the project root.