Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion bridge/symfony-console/src/Command/Run.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
}
}
161 changes: 161 additions & 0 deletions bridge/symfony-console/tests/Acceptance/RunCommandTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
<?php

declare(strict_types=1);

namespace Tests\Bridge\SymfonyConsole\Acceptance;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
use Testo\Assert;
use Testo\Bridge\Symfony\Console\Command\Run;
use Testo\Codecov\Covers;
use Testo\Lifecycle\AfterTest;
use Testo\Lifecycle\BeforeTest;
use Testo\Output\Html\HtmlPlugin;
use Testo\Test;
use Tests\Bridge\SymfonyConsole\Testo\Sandbox;

/**
* Acceptance tests for the `--log-html` flag of `testo run`.
*
* Each test drives the real command end-to-end — Symfony parses the flags, {@see Run::initialize()}
* normalizes them and a whole nested run happens in-process — inside a fresh {@see Sandbox} holding a
* minimal config that points at a one-test stub suite. Assertions look at the exit code, the resolved
* option and the files the run leaves on disk: the same surface a user observes after
* `vendor/bin/testo --log-html`.
*
* The sandbox config swaps the default reporter for a fresh {@see HtmlPlugin::inert()} of its own: the
* application defaults hold one shared instance per process, and its single-shot guard is already spent
* by the outer run that executes these tests — without the swap a nested run could write no report no
* matter what the flag says.
*/
#[Test]
#[Covers(Run::class)]
final class RunCommandTest
{
private Sandbox $sandbox;

public function bareLogHtmlWritesTheReportToTheDefaultLocation(): void
{
$tester = $this->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 <<<PHP
<?php

declare(strict_types=1);

use Testo\\Application\\Config\\ApplicationConfig;
use Testo\\Application\\Config\\FinderConfig;
use Testo\\Application\\Config\\Plugin\\ApplicationPlugins;
use Testo\\Application\\Config\\SuiteConfig;
use Testo\\Output\\Html\\HtmlPlugin;

return new ApplicationConfig(
src: [],
suites: [
new SuiteConfig(
'Stub',
location: new FinderConfig(include: [{$stub}]),
),
],
plugins: ApplicationPlugins::without(HtmlPlugin::class)->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<string, string|bool|null> $input
*/
private function run(array $input = []): CommandTester
{
$tester = new CommandTester(new Run());
$tester->execute(
$input,
['interactive' => false, 'capture_stderr_separately' => false],
);

return $tester;
}
}
21 changes: 21 additions & 0 deletions bridge/symfony-console/tests/Stub/Run/PassingCase.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Tests\Bridge\SymfonyConsole\Stub\Run;

use Testo\Assert;
use Testo\Test;

/**
* A minimal case for the `run` command acceptance tests to execute: one test that passes and asserts
* something, so a nested run has a suite, a case, a test and a green status to report.
*/
#[Test]
final class PassingCase
{
public function itPasses(): void
{
Assert::true(true);
}
}
3 changes: 2 additions & 1 deletion core/Output/Html/HtmlPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@
* ```
*
* An inert copy — {@see inert()} — is part of the application defaults, so the `--log-html=<path>` and
* `--log-report=<path>` flags have something to activate without any change to `testo.php`. An instance
* `--log-report=<path>` 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
Expand Down
3 changes: 2 additions & 1 deletion skills/testo-run-tests/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading