diff --git a/.github/wiki b/.github/wiki index 19c36c7374..989c09cc04 160000 --- a/.github/wiki +++ b/.github/wiki @@ -1 +1 @@ -Subproject commit 19c36c73743fdc0720bad2c1a5dc9eca4a97bf88 +Subproject commit 989c09cc04a27bd9236f56f743796d712ed3ee12 diff --git a/CHANGELOG.md b/CHANGELOG.md index 567468e7d0..2b9e752c9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Keep structured DevTools command output parseable and agent-friendly by exposing the PHPUnit agent-reporter payload under nested `output`, suppressing intermediary progress logs in JSON modes, and normalizing orchestrated subprocess payloads before the final JSON envelope is emitted (#248) - Register ``ergebnis/phpunit-agent-reporter`` in the packaged ``phpunit.xml`` so AI agents receive compact PHPUnit JSON summaries without changing consumer overrides manually (#327) - Let `wiki`, `docs`, `tests`, `metrics`, and `reports` skip gracefully for guide-only repositories while keeping wiki/report workflows and published preview links aligned with the artifacts that were actually generated (#325) diff --git a/README.md b/README.md index 1be24515c5..f4304feba6 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ across Fast Forward libraries. - Synchronizes packaged skills and project-agent prompts into consumer `.agents/skills` and `.agents/agents` directories using safe link-based updates +- Supports guide-only and automation-only repositories by skipping PHPUnit or + wiki generation gracefully when no runnable PHP surface exists - Works both as a Composer plugin and as a local binary - Preserves local overrides through consumer-first configuration resolution @@ -71,6 +73,8 @@ You can also run individual commands for specific development tasks: ```bash # Run PHPUnit tests composer dev-tools tests +composer dev-tools tests --json +composer dev-tools tests --pretty-json # Analyze missing, unused, misplaced, and outdated Composer dependencies composer dependencies @@ -120,6 +124,7 @@ composer phpdoc # Generate HTML API documentation using phpDocumentor composer docs +composer docs --source=docs/user-guide # Generate Markdown documentation for the wiki composer wiki @@ -180,6 +185,14 @@ The `metrics` command ships with `phpmetrics/phpmetrics` as a direct dependency of `fast-forward/dev-tools`, so consumer repositories can generate metrics reports without extra setup. +Guide-only repositories and workflow-only repositories can still use the +packaged command surface. When no runnable PHPUnit surface exists, `tests` +returns a controlled warning instead of failing. When a repository ships +guides without PSR-4 source paths, `docs` builds the guide site without trying +to synthesize API pages. When `.github/wiki` is absent and no wiki has been +initialized yet, `wiki` now skips generation with a warning instead of failing +the whole automation run. + The changelog commands manage Keep a Changelog 1.1.0 files without requiring extra tooling in the consumer repository. `changelog:entry` bootstraps a missing changelog file on first use, `changelog:check` enforces meaningful @@ -209,16 +222,35 @@ automatically when the runtime is detected as agent-driven. For prints raw values so release workflows can keep capturing semantic versions and piping rendered release notes directly into GitHub releases. +`--pretty-json` intentionally remains valid JSON. DevTools does not inject ANSI +escape sequences into that mode today because preserving a parseable payload +takes precedence over terminal-only color. Where orchestrated tools can expose +structured subprocess results safely, DevTools prefers adding stable fields to +the JSON context rather than coloring otherwise strict JSON output. + +For the `tests` command, structured runs now capture the bundled PHPUnit +agent-reporter payload inside `context.output` while preserving the normal +DevTools JSON envelope. That means the top-level command still emits one final +document, and consumers can inspect stable nested keys such as +`context.output.result`, `context.output.summary`, optional +`context.output.details`, and `context.output.coverage` when minimum-coverage +validation is active. + Progress output is disabled by default on the commands that support transient rendering, and `--progress` re-enables it for human-readable terminal runs. When `--json` or `--pretty-json` is active on commands that orchestrate other tools, DevTools keeps progress suppressed, forwards JSON flags where the underlying tool supports structured output, and otherwise falls back to -quieter subprocess modes so the captured payload stays machine-readable. In -GitHub Actions, queued subprocess output is grouped into collapsible sections, -and logged failures emit native workflow error annotations, including file and -line metadata when commands provide it. The packaged tests, reports, wiki, and -changelog workflows also append concise Markdown outcomes to +quieter subprocess modes so the captured payload stays machine-readable. The +`tests` command now captures the bundled PHPUnit agent-reporter payload in +structured runs and stores it in `context.output`, preserving `result`, +`summary`, optional `details`, and a `raw_output` fallback when coverage or +other PHPUnit text is emitted before the final reporter JSON. In GitHub +Actions, queued subprocess output is +grouped into collapsible sections, and logged failures emit native workflow +error annotations, including file and line metadata when commands provide it. +The packaged tests, reports, wiki, and changelog workflows also append concise +Markdown outcomes to `GITHUB_STEP_SUMMARY` so maintainers can scan versions, URLs, preview refs, verification status, and release results without expanding full logs. This repository also keeps a bounded retry workflow that reruns failed jobs once @@ -324,8 +356,8 @@ authoring. - `Composer Plugin` - `FastForward\DevTools\Composer\Plugin` exposes the packaged command set to Composer and runs `dev-tools:sync` after install and update. -- `DevTools Container` - `FastForward\DevTools\Console\DevTools::create()` - builds a shared container from `DevToolsServiceProvider`, which wires +- `DevTools Container` - `FastForward\DevTools\Container\ContainerFactory::get(FastForward\DevTools\Console\DevTools::class)` + resolves the shared application from `DevToolsServiceProvider`, which wires process execution, filesystem access, changelog services, Git helpers, diffing, reporting, and template loading. - `Generic Link Synchronization` - diff --git a/bin/dev-tools.php b/bin/dev-tools.php index d3eba1dae7..cf0b8bf72d 100644 --- a/bin/dev-tools.php +++ b/bin/dev-tools.php @@ -20,10 +20,18 @@ namespace FastForward\DevTools; use FastForward\DevTools\Console\DevTools; +use FastForward\DevTools\Container\ContainerFactory; -$projectVendorAutoload = \dirname(__DIR__, 4) . '/vendor/autoload.php'; -$pluginVendorAutoload = \dirname(__DIR__) . '/vendor/autoload.php'; +$autoloadCandidates = [\dirname(__DIR__, 4) . '/vendor/autoload.php', \dirname(__DIR__) . '/vendor/autoload.php']; -require_once file_exists($projectVendorAutoload) ? $projectVendorAutoload : $pluginVendorAutoload; +foreach ($autoloadCandidates as $autoloadCandidate) { + if (is_file($autoloadCandidate)) { + require_once $autoloadCandidate; -DevTools::create()->run(); + exit(ContainerFactory::get(DevTools::class)->run()); + } +} + +fprintf(\STDERR, "Could not locate Composer autoload.php for fast-forward/dev-tools.\n"); + +exit(1); diff --git a/composer.json b/composer.json index cecfff73f3..345b18fb7d 100644 --- a/composer.json +++ b/composer.json @@ -44,8 +44,8 @@ "container-interop/service-provider": "^0.4.1", "dg/bypass-finals": "^1.9", "ergebnis/agent-detector": "^1.1", - "ergebnis/phpunit-agent-reporter": "^0.3", "ergebnis/composer-normalize": "^2.51", + "ergebnis/phpunit-agent-reporter": "^0.3", "ergebnis/rector-rules": "^1.18", "fakerphp/faker": "^1.24", "fast-forward/phpdoc-bootstrap-template": "^2.0", diff --git a/docs/advanced/phpunit-extension.rst b/docs/advanced/phpunit-extension.rst index 42eac8af71..b0c9d18378 100644 --- a/docs/advanced/phpunit-extension.rst +++ b/docs/advanced/phpunit-extension.rst @@ -19,6 +19,9 @@ Runtime Chain builds a summary notification and sends it when the run finishes. 5. ``Ergebnis\PHPUnit\AgentReporter\Extension`` replaces PHPUnit's normal output with a compact JSON report when an agent runtime is detected. +6. In structured DevTools runs, ``tests`` forces the same reporter path for + the PHPUnit subprocess so the final nested payload remains deterministic + even when the surrounding process would not naturally look agent-driven. Why This Helps Consumer Projects -------------------------------- @@ -29,6 +32,8 @@ Why This Helps Consumer Projects scrollback; - agent-driven runs consume far less terminal context while still keeping failure details and PHPUnit exit semantics intact; +- DevTools can preserve a single top-level JSON document while nesting the + compact PHPUnit summary under ``context.output`` for bot-friendly parsing; - event counts are available to the notification layer without adding ad-hoc test code. diff --git a/docs/api/composer-integration.rst b/docs/api/composer-integration.rst index d08a0bbce5..9aa33d7348 100644 --- a/docs/api/composer-integration.rst +++ b/docs/api/composer-integration.rst @@ -12,8 +12,9 @@ Startup Chain ``vendor/autoload.php`` and falls back to the package autoloader. 3. ``bin/dev-tools.php`` starts ``FastForward\DevTools\Console\DevTools`` and appends ``--no-plugins``. -4. ``FastForward\DevTools\Console\DevTools::create()`` builds the shared - container from ``FastForward\DevTools\ServiceProvider\DevToolsServiceProvider``. +4. ``FastForward\DevTools\Container\ContainerFactory::get(FastForward\DevTools\Console\DevTools::class)`` + resolves the shared application container from + ``FastForward\DevTools\ServiceProvider\DevToolsServiceProvider``. 5. ``FastForward\DevTools\Console\CommandLoader\DevToolsCommandLoader`` lazily discovers ``#[AsCommand]`` classes from the command namespace. 6. ``FastForward\DevTools\Composer\Capability\DevToolsCommandProvider`` later diff --git a/docs/commands/docs.rst b/docs/commands/docs.rst index f50855e8b3..351cf4c98f 100644 --- a/docs/commands/docs.rst +++ b/docs/commands/docs.rst @@ -103,9 +103,14 @@ Exit Codes Behavior --------- -- ``docs/`` must exist unless you pass another ``--source`` directory. +- The default ``docs`` source directory is optional. When it is absent, the + command still generates API pages if PSR-4 source paths are available. +- A custom explicit ``--source`` path must exist; otherwise the command fails + fast. - API pages are built from the PSR-4 paths declared in ``composer.json``. - Guide pages are built from the selected source directory. +- Repositories without PSR-4 source paths can still generate a guides-only + site from the selected source directory. - Cache stays enabled by default; omit both flags to keep the command default, pass ``--cache`` to force it on, and pass ``--no-cache`` to force it off. - When ``--cache-dir`` is omitted, phpDocumentor keeps its default cache diff --git a/docs/commands/reports.rst b/docs/commands/reports.rst index 1dd5d23383..262c41eced 100644 --- a/docs/commands/reports.rst +++ b/docs/commands/reports.rst @@ -123,6 +123,9 @@ Behavior - When ``--json`` or ``--pretty-json`` is active, it forwards JSON mode to the ``docs``, ``tests``, and ``metrics`` subprocesses and suppresses transient progress output where those tools support it. +- Nested ``docs`` and ``tests`` stages can now skip gracefully with warnings in + guide-only or automation-only repositories, while ``reports`` still returns a + successful aggregate result for the stages that were actually generated. - Passes ``--junit /junit.xml`` to the metrics step. - Used by the ``standards`` command as the final phase. - This is the reporting stage used by GitHub Pages. diff --git a/docs/commands/tests.rst b/docs/commands/tests.rst index 6a25cf21e1..b81039d1e7 100644 --- a/docs/commands/tests.rst +++ b/docs/commands/tests.rst @@ -71,7 +71,8 @@ Options ``--pretty-json`` Emit the same structured payload with indentation for terminal inspection. This also suppresses PHPUnit progress output automatically so the JSON - payload is not polluted by transient progress rendering. + payload is not polluted by transient progress rendering. The output remains + valid JSON and intentionally does not include ANSI color escapes. Examples -------- @@ -112,6 +113,13 @@ Run with minimum coverage enforcement: composer tests --min-coverage=80 +Run with structured JSON output: + +.. code-block:: bash + + composer tests --json + composer tests --pretty-json + Run without cache: .. code-block:: bash @@ -149,6 +157,10 @@ Behavior --------- - Local ``phpunit.xml`` is preferred over the packaged default. +- When the default ``tests`` path is absent and the project exposes no + testable PHP source files, the command exits successfully with a warning + instead of failing the whole automation flow. +- A custom explicit tests path that does not exist still fails fast. - Coverage filters are automatically applied to all PSR-4 paths from composer.json. - Multiple coverage formats are generated: HTML, Testdox HTML, Clover XML, and PHP. - Cache stays enabled by default; omit both flags to keep the command default, @@ -160,5 +172,25 @@ Behavior - progress output is disabled by default. - ``--json`` and ``--pretty-json`` keep progress output disabled so the structured payload stays clean, even when ``--progress`` is provided. +- in agent-driven runs outside the Composer test suite, the command also + switches to the same structured capture mode automatically. +- when structured capture is active and PHPUnit emits agent-reporter JSON, the + command stores that payload inside ``output`` while keeping the standard + DevTools JSON envelope. ``--json`` and ``--pretty-json`` therefore expose + the same structured result, with formatting as the only difference. +- the command forces the bundled PHPUnit agent-reporter extension for + structured runs so the nested payload stays compact and stable even when the + surrounding runtime would not normally look like an agent. +- in structured mode, the command suppresses intermediary ``Running...`` log + records so the output stream contains a single final JSON document. +- when structured capture is active but PHPUnit does not emit parseable JSON, + the command preserves the raw subprocess text inside ``output.raw_output`` + instead of dropping it. +- when coverage generation or other PHPUnit text appears before the final + reporter payload, the command preserves that prelude in + ``output.raw_output`` while keeping the main JSON result parseable. +- when ``--min-coverage`` is used in structured mode, the command appends a + ``coverage`` object under ``output`` and flips ``output.result`` to + ``failure`` if the threshold is not met. - The command fails if minimum coverage is not met (when ``--min-coverage`` is set). - The packaged configuration registers the DevTools PHPUnit extension. diff --git a/docs/commands/wiki.rst b/docs/commands/wiki.rst index 3fe6e88615..f78defe30c 100644 --- a/docs/commands/wiki.rst +++ b/docs/commands/wiki.rst @@ -90,6 +90,10 @@ Behavior --------- - Default output directory is ``.github/wiki``. +- When the default target does not exist yet and ``--init`` is not requested, + the command skips with a controlled warning instead of failing. +- Repositories without PHP API surface also skip wiki generation with a + warning, because the current wiki renderer only emits API pages. - Cache stays enabled by default; omit both flags to keep the command default, pass ``--cache`` to force it on, and pass ``--no-cache`` to force it off. - When ``--cache-dir`` is omitted, phpDocumentor keeps its default cache diff --git a/docs/getting-started/quickstart.rst b/docs/getting-started/quickstart.rst index a57da512e8..71240525a4 100644 --- a/docs/getting-started/quickstart.rst +++ b/docs/getting-started/quickstart.rst @@ -4,16 +4,16 @@ Quickstart This walkthrough is the fastest way to get a new library into a healthy state. 1. Install the package. -2. Create a minimal guide directory. +2. Create a guide directory if the repository will publish guides. 3. Synchronize shared automation, packaged skills, and packaged agents. 4. Run the focused commands once. 5. Run the full suite before opening a pull request. -Create the Minimum Guide ------------------------- +Optional Guide Setup +-------------------- -The ``docs`` command fails early when ``docs/`` does not exist. A tiny -starting page is enough for the first successful run. +If the repository will publish guides, a tiny starting page is enough for the +first successful ``docs`` run. Create the directory: @@ -30,10 +30,14 @@ Create ``docs/index.rst`` with content such as: Welcome to the project documentation. +Repositories that only ship PHP code can skip this step and still generate API +documentation. Repositories that only ship guides can also use the same +``docs`` command even without PSR-4 source paths. + Run the First Commands ---------------------- -Once the package is installed and the guide directory exists, run: +Once the package is installed, run: .. code-block:: bash @@ -57,9 +61,11 @@ What Each Command Proves safely into ``.agents/agents`` without copying files into the consumer repository. - ``composer tests`` proves the packaged or local PHPUnit - configuration can execute the current test suite. + configuration can execute the current test suite, or skip gracefully when + the repository intentionally has no runnable PHPUnit surface yet. - ``composer docs`` proves the PSR-4 source paths and the guide - directory are usable by phpDocumentor. + directory are usable by phpDocumentor, whichever of those surfaces the + repository actually provides. - ``composer dev-tools`` proves the complete pipeline can run in the expected order. diff --git a/docs/internals/architecture.rst b/docs/internals/architecture.rst index d7c679d256..666be893c4 100644 --- a/docs/internals/architecture.rst +++ b/docs/internals/architecture.rst @@ -10,8 +10,9 @@ Local Command Lifecycle 1. ``bin/dev-tools`` loads ``bin/dev-tools.php``. 2. ``bin/dev-tools.php`` prefers the consumer ``vendor/autoload.php`` and falls back to the package autoloader. -3. ``FastForward\DevTools\Console\DevTools::create()`` builds a shared - container from ``FastForward\DevTools\ServiceProvider\DevToolsServiceProvider``. +3. ``FastForward\DevTools\Container\ContainerFactory::get(FastForward\DevTools\Console\DevTools::class)`` + resolves the shared application container from + ``FastForward\DevTools\ServiceProvider\DevToolsServiceProvider``. 4. ``FastForward\DevTools\Console\CommandLoader\DevToolsCommandLoader`` lazily discovers ``#[AsCommand]`` classes and resolves them from that container. @@ -61,7 +62,7 @@ Dependency Injection -------------------- ``DevToolsServiceProvider`` builds the shared application container used by -``DevTools::create()``. Most commands receive collaborators through +``ContainerFactory::get(DevTools::class)``. Most commands receive collaborators through constructor injection once resolved by that container, while command discovery itself stays lazy through ``DevToolsCommandLoader``. diff --git a/docs/running/specialized-commands.rst b/docs/running/specialized-commands.rst index fc4d83b277..ebd5fa52fb 100644 --- a/docs/running/specialized-commands.rst +++ b/docs/running/specialized-commands.rst @@ -63,6 +63,12 @@ Important details: - ``--progress`` re-enables PHPUnit progress output in text mode; - ``--json`` and ``--pretty-json`` still suppress PHPUnit progress output automatically; +- ``--pretty-json`` stays valid JSON and does not add ANSI color escapes; +- in agent-driven runs, the command also captures PHPUnit output in structured + mode automatically and stores the bundled PHPUnit agent-reporter payload in + ``output``. ``--json`` and ``--pretty-json`` therefore keep the same + structured shape, with ``raw_output`` preserved under ``output`` when + PHPUnit writes extra text before the final JSON; - the packaged configuration registers the DevTools PHPUnit extension. ``dependencies`` diff --git a/docs/usage/testing-and-coverage.rst b/docs/usage/testing-and-coverage.rst index 646a2c6ca7..c4623d1d85 100644 --- a/docs/usage/testing-and-coverage.rst +++ b/docs/usage/testing-and-coverage.rst @@ -17,6 +17,8 @@ When you run ``tests``, DevTools: explicit force-off flag for the current run; - uses the selected workspace ``cache/phpunit`` directory only when caching stays enabled; +- skips the command with a warning when the repository has no default + ``tests`` directory and no testable PHP source surface; - can generate HTML coverage, Testdox, Clover, and raw PHP coverage output when ``--coverage`` is provided. @@ -29,6 +31,8 @@ Useful Examples composer tests -- --filter=PluginTest composer tests --coverage=.dev-tools/coverage composer tests --no-cache --bootstrap=tests/bootstrap.php + composer tests --json + composer tests --pretty-json Coverage Outputs ---------------- @@ -59,6 +63,10 @@ The packaged ``phpunit.xml`` registers: default verbose terminal output with a compact JSON summary when an agent runtime is detected. +When ``tests`` itself runs in structured mode, DevTools also forces the +reporter path for the PHPUnit subprocess so the nested payload remains compact +and parseable for bots and agent workflows. + Programmatic Coverage Access ----------------------------- @@ -83,7 +91,10 @@ When to Override Locally Create your own ``phpunit.xml`` in the consumer project when you need a different bootstrap file, extra extensions, or alternative strictness flags. -DevTools will prefer the local file automatically. +DevTools will prefer the local file automatically, but consumer projects that +replace the packaged configuration must re-register both bundled extensions if +they still want desktop notifications, BypassFinals support, and compact +agent-oriented JSON output. .. note:: diff --git a/src/Composer/Capability/DevToolsCommandProvider.php b/src/Composer/Capability/DevToolsCommandProvider.php index d8014ca62b..105d49f508 100644 --- a/src/Composer/Capability/DevToolsCommandProvider.php +++ b/src/Composer/Capability/DevToolsCommandProvider.php @@ -23,6 +23,7 @@ use FastForward\DevTools\Composer\Command\ProxyCommand; use FastForward\DevTools\Composer\DevToolsPluginInterface; use FastForward\DevTools\Console\DevTools; +use FastForward\DevTools\Container\ContainerFactory; use Symfony\Component\Console\Command\Command; /** @@ -54,7 +55,7 @@ public function getCommands() { $commands = []; - foreach (DevTools::create()->all() as $registeredName => $command) { + foreach (ContainerFactory::get(DevTools::class)->all() as $registeredName => $command) { /** * Composer plugin registrations must be canonicalized to one command per Symfony command. * The application exposes alias keys in `all()`, but Composer interprets each entry as diff --git a/src/Config/ComposerDependencyAnalyserConfig.php b/src/Config/ComposerDependencyAnalyserConfig.php index f62f95f4ba..b1044307be 100644 --- a/src/Config/ComposerDependencyAnalyserConfig.php +++ b/src/Config/ComposerDependencyAnalyserConfig.php @@ -53,6 +53,7 @@ final class ComposerDependencyAnalyserConfig * @var array */ public const array DEFAULT_PACKAGED_UNUSED_DEPENDENCIES = [ + 'ergebnis/phpunit-agent-reporter', 'ergebnis/composer-normalize', 'fakerphp/faker', 'fast-forward/phpdoc-bootstrap-template', diff --git a/src/Console/Command/AgentsCommand.php b/src/Console/Command/AgentsCommand.php index d26595ac31..c4bdce0855 100644 --- a/src/Console/Command/AgentsCommand.php +++ b/src/Console/Command/AgentsCommand.php @@ -24,7 +24,6 @@ use FastForward\DevTools\Filesystem\FilesystemInterface; use FastForward\DevTools\Path\DevToolsPathResolver; use FastForward\DevTools\Sync\PackagedDirectorySynchronizer; -use Psr\Log\LoggerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -48,12 +47,10 @@ final class AgentsCommand extends Command /** * @param PackagedDirectorySynchronizer $synchronizer * @param FilesystemInterface $filesystem - * @param LoggerInterface $logger */ public function __construct( private readonly PackagedDirectorySynchronizer $synchronizer, private readonly FilesystemInterface $filesystem, - private readonly LoggerInterface $logger, ) { parent::__construct(); } @@ -80,7 +77,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $packageAgentsPath = DevToolsPathResolver::getPackagePath(self::AGENTS_DIRECTORY); $agentsDir = $this->filesystem->getAbsolutePath(self::AGENTS_DIRECTORY); - $this->logger->info('Starting agents synchronization...'); + $this->log('Starting agents synchronization...', $input); if (! $this->filesystem->exists($packageAgentsPath)) { return $this->failure( @@ -99,7 +96,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if (! $this->filesystem->exists($agentsDir)) { $this->filesystem->mkdir($agentsDir); $directoryCreated = true; - $this->logger->info('Created .agents/agents directory.'); + $this->log('Created .agents/agents directory.', $input); } $result = $this->synchronizer->synchronize($agentsDir, $packageAgentsPath, self::AGENTS_DIRECTORY); diff --git a/src/Console/Command/ChangelogCheckCommand.php b/src/Console/Command/ChangelogCheckCommand.php index eaebc859b7..c9d09aa1cb 100644 --- a/src/Console/Command/ChangelogCheckCommand.php +++ b/src/Console/Command/ChangelogCheckCommand.php @@ -23,7 +23,6 @@ use FastForward\DevTools\Changelog\Checker\UnreleasedEntryCheckerInterface; use FastForward\DevTools\Console\Input\HasJsonOption; use FastForward\DevTools\Filesystem\FilesystemInterface; -use Psr\Log\LoggerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -45,12 +44,10 @@ final class ChangelogCheckCommand extends Command /** * @param FilesystemInterface $filesystem * @param UnreleasedEntryCheckerInterface $unreleasedEntryChecker - * @param LoggerInterface $logger */ public function __construct( private readonly FilesystemInterface $filesystem, private readonly UnreleasedEntryCheckerInterface $unreleasedEntryChecker, - private readonly LoggerInterface $logger, ) { parent::__construct(); } diff --git a/src/Console/Command/ChangelogEntryCommand.php b/src/Console/Command/ChangelogEntryCommand.php index 7de3a385fe..5808d8da2d 100644 --- a/src/Console/Command/ChangelogEntryCommand.php +++ b/src/Console/Command/ChangelogEntryCommand.php @@ -25,7 +25,6 @@ use FastForward\DevTools\Changelog\Manager\ChangelogManagerInterface; use FastForward\DevTools\Console\Input\HasJsonOption; use FastForward\DevTools\Filesystem\FilesystemInterface; -use Psr\Log\LoggerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -48,12 +47,10 @@ final class ChangelogEntryCommand extends Command /** * @param FilesystemInterface $filesystem * @param ChangelogManagerInterface $changelogManager - * @param LoggerInterface $logger */ public function __construct( private readonly FilesystemInterface $filesystem, private readonly ChangelogManagerInterface $changelogManager, - private readonly LoggerInterface $logger, ) { parent::__construct(); } diff --git a/src/Console/Command/ChangelogNextVersionCommand.php b/src/Console/Command/ChangelogNextVersionCommand.php index 18b9400702..3d3a84f24a 100644 --- a/src/Console/Command/ChangelogNextVersionCommand.php +++ b/src/Console/Command/ChangelogNextVersionCommand.php @@ -24,7 +24,6 @@ use FastForward\DevTools\Changelog\Manager\ChangelogManagerInterface; use FastForward\DevTools\Console\Input\HasJsonOption; use FastForward\DevTools\Filesystem\FilesystemInterface; -use Psr\Log\LoggerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -46,12 +45,10 @@ final class ChangelogNextVersionCommand extends Command /** * @param FilesystemInterface $filesystem * @param ChangelogManagerInterface $changelogManager - * @param LoggerInterface $logger */ public function __construct( private readonly FilesystemInterface $filesystem, private readonly ChangelogManagerInterface $changelogManager, - private readonly LoggerInterface $logger, ) { parent::__construct(); } diff --git a/src/Console/Command/ChangelogPromoteCommand.php b/src/Console/Command/ChangelogPromoteCommand.php index 6425f07159..e4a6a0a9b8 100644 --- a/src/Console/Command/ChangelogPromoteCommand.php +++ b/src/Console/Command/ChangelogPromoteCommand.php @@ -25,7 +25,6 @@ use FastForward\DevTools\Console\Input\HasJsonOption; use FastForward\DevTools\Filesystem\FilesystemInterface; use Psr\Clock\ClockInterface; -use Psr\Log\LoggerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -49,13 +48,11 @@ final class ChangelogPromoteCommand extends Command * @param FilesystemInterface $filesystem * @param ChangelogManagerInterface $changelogManager * @param ClockInterface $clock - * @param LoggerInterface $logger */ public function __construct( private readonly FilesystemInterface $filesystem, private readonly ChangelogManagerInterface $changelogManager, private readonly ClockInterface $clock, - private readonly LoggerInterface $logger, ) { parent::__construct(); } diff --git a/src/Console/Command/ChangelogShowCommand.php b/src/Console/Command/ChangelogShowCommand.php index dbc54c59e8..44f2a45afe 100644 --- a/src/Console/Command/ChangelogShowCommand.php +++ b/src/Console/Command/ChangelogShowCommand.php @@ -24,7 +24,6 @@ use FastForward\DevTools\Changelog\Manager\ChangelogManagerInterface; use FastForward\DevTools\Console\Input\HasJsonOption; use FastForward\DevTools\Filesystem\FilesystemInterface; -use Psr\Log\LoggerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -44,12 +43,10 @@ final class ChangelogShowCommand extends Command /** * @param FilesystemInterface $filesystem * @param ChangelogManagerInterface $changelogManager - * @param LoggerInterface $logger */ public function __construct( private readonly FilesystemInterface $filesystem, private readonly ChangelogManagerInterface $changelogManager, - private readonly LoggerInterface $logger, ) { parent::__construct(); } diff --git a/src/Console/Command/CodeOwnersCommand.php b/src/Console/Command/CodeOwnersCommand.php index 669109f137..3734d55dda 100644 --- a/src/Console/Command/CodeOwnersCommand.php +++ b/src/Console/Command/CodeOwnersCommand.php @@ -24,7 +24,6 @@ use FastForward\DevTools\CodeOwners\CodeOwnersGenerator; use FastForward\DevTools\Filesystem\FilesystemInterface; use FastForward\DevTools\Resource\FileDiffer; -use Psr\Log\LoggerInterface; use Psr\Log\LogLevel; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -53,14 +52,12 @@ final class CodeOwnersCommand extends Command * @param CodeOwnersGenerator $generator the generator used to infer and render CODEOWNERS contents * @param FilesystemInterface $filesystem the filesystem used to read and write the target file * @param FileDiffer $fileDiffer the differ used to report managed-file drift - * @param LoggerInterface $logger the output-aware logger * @param SymfonyStyle $io the SymfonyStyle instance for interactive prompts */ public function __construct( private readonly CodeOwnersGenerator $generator, private readonly FilesystemInterface $filesystem, private readonly FileDiffer $fileDiffer, - private readonly LoggerInterface $logger, private readonly SymfonyStyle $io, ) { parent::__construct(); @@ -153,21 +150,22 @@ protected function execute(InputInterface $input, OutputInterface $output): int : \sprintf('Updating managed file %s from generated CODEOWNERS content.', $targetPath), ); - $this->notice($comparison->getSummary(), $input, [ + $this->log($comparison->getSummary(), $input, [ 'target_path' => $targetPath, - ]); + ], LogLevel::NOTICE); if ($comparison->isChanged()) { $consoleDiff = $this->fileDiffer->formatForConsole($comparison->getDiff(), $output->isDecorated()); if (null !== $consoleDiff) { - $this->notice( + $this->log( $consoleDiff, $input, [ 'target_path' => $targetPath, 'diff' => $comparison->getDiff(), ], + LogLevel::NOTICE, ); } } diff --git a/src/Console/Command/CodeStyleCommand.php b/src/Console/Command/CodeStyleCommand.php index 9a59f933af..f3953680ef 100644 --- a/src/Console/Command/CodeStyleCommand.php +++ b/src/Console/Command/CodeStyleCommand.php @@ -24,7 +24,6 @@ use FastForward\DevTools\Path\DevToolsPathResolver; use FastForward\DevTools\Process\ProcessBuilderInterface; use FastForward\DevTools\Process\ProcessQueueInterface; -use Psr\Log\LoggerInterface; use Symfony\Component\Config\FileLocatorInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -63,13 +62,11 @@ final class CodeStyleCommand extends Command * @param FileLocatorInterface $fileLocator locates the configuration file required by EasyCodingStandard * @param ProcessBuilderInterface $processBuilder builds the process instances used to execute Composer and ECS commands * @param ProcessQueueInterface $processQueue queues and executes the generated processes in the required order - * @param LoggerInterface $logger logs command feedback */ public function __construct( private readonly FileLocatorInterface $fileLocator, private readonly ProcessBuilderInterface $processBuilder, private readonly ProcessQueueInterface $processQueue, - private readonly LoggerInterface $logger, ) { parent::__construct(); } @@ -121,7 +118,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $fix = (bool) $input->getOption('fix'); $progress = ! $jsonOutput && (bool) $input->getOption('progress'); - $this->logger->info('Running code style checks and fixes...'); + $this->log('Running code style checks and fixes...', $input); $composerUpdate = $this->processBuilder ->withArgument('--lock') @@ -164,14 +161,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int return $this->success('Code style checks completed successfully.', $input, [ 'fix' => $fix, 'config' => self::CONFIG, - 'process_output' => $processOutput instanceof BufferedOutput ? $processOutput->fetch() : null, + 'output' => $processOutput, ]); } return $this->failure('Code style checks failed.', $input, [ 'fix' => $fix, 'config' => self::CONFIG, - 'process_output' => $processOutput instanceof BufferedOutput ? $processOutput->fetch() : null, + 'output' => $processOutput, ]); } } diff --git a/src/Console/Command/CopyResourceCommand.php b/src/Console/Command/CopyResourceCommand.php index 950a485df3..26a32c821c 100644 --- a/src/Console/Command/CopyResourceCommand.php +++ b/src/Console/Command/CopyResourceCommand.php @@ -24,7 +24,6 @@ use FastForward\DevTools\Filesystem\FinderFactoryInterface; use FastForward\DevTools\Filesystem\FilesystemInterface; use FastForward\DevTools\Resource\FileDiffer; -use Psr\Log\LoggerInterface; use Psr\Log\LogLevel; use Symfony\Component\Config\FileLocatorInterface; use Symfony\Component\Console\Attribute\AsCommand; @@ -56,7 +55,6 @@ final class CopyResourceCommand extends Command * @param FileLocatorInterface $fileLocator the locator used to resolve source resources * @param FinderFactoryInterface $finderFactory the factory used to create finders for directory resources * @param FileDiffer $fileDiffer the service used to summarize overwrite changes - * @param LoggerInterface $logger the output-aware logger * @param SymfonyStyle $io the input/output service used to interact with the user */ public function __construct( @@ -64,7 +62,6 @@ public function __construct( private readonly FileLocatorInterface $fileLocator, private readonly FinderFactoryInterface $finderFactory, private readonly FileDiffer $fileDiffer, - private readonly LoggerInterface $logger, private readonly SymfonyStyle $io, ) { parent::__construct(); @@ -162,11 +159,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int * @param string $sourcePath the resolved source directory * @param string $targetPath the resolved target directory * @param bool $overwrite whether existing files MAY be overwritten + * @param bool $dryRun whether the command is previewing changes only + * @param bool $check whether the command SHOULD fail on detected drift + * @param bool $interactive whether the command SHOULD prompt before overwriting drifted files + * @param InputInterface $input the originating command input * @param OutputInterface $output the output used to report copy results - * @param bool $dryRun - * @param bool $check - * @param bool $interactive - * @param InputInterface $input * * @return int the command status code */ @@ -213,11 +210,11 @@ private function copyDirectory( * @param string $sourcePath the resolved source file * @param string $targetPath the resolved target file * @param bool $overwrite whether an existing target file MAY be overwritten + * @param bool $dryRun whether the command is previewing changes only + * @param bool $check whether the command SHOULD fail on detected drift + * @param bool $interactive whether the command SHOULD prompt before overwriting drifted files + * @param InputInterface $input the originating command input * @param OutputInterface $output the output used to report copy results - * @param bool $dryRun - * @param bool $check - * @param bool $interactive - * @param InputInterface $input * * @return int the command status code */ @@ -246,20 +243,21 @@ private function copyFile( if (($overwrite || $dryRun || $check || $interactive) && $this->filesystem->exists($targetPath)) { $comparison = $this->fileDiffer->diff($sourcePath, $targetPath); - $this->logger->notice( + $this->log( $comparison->getSummary(), + $input, [ - 'input' => $input, 'source_path' => $sourcePath, 'target_path' => $targetPath, ], + LogLevel::NOTICE, ); if ($comparison->isChanged()) { $consoleDiff = $this->fileDiffer->formatForConsole($comparison->getDiff(), $output->isDecorated()); if (null !== $consoleDiff) { - $this->notice( + $this->log( $consoleDiff, $input, [ @@ -267,6 +265,7 @@ private function copyFile( 'target_path' => $targetPath, 'diff' => $comparison->getDiff(), ], + LogLevel::NOTICE, ); } } diff --git a/src/Console/Command/DependenciesCommand.php b/src/Console/Command/DependenciesCommand.php index 11babe2b91..4f4e5c1077 100644 --- a/src/Console/Command/DependenciesCommand.php +++ b/src/Console/Command/DependenciesCommand.php @@ -26,7 +26,6 @@ use FastForward\DevTools\Process\ProcessBuilderInterface; use FastForward\DevTools\Process\ProcessQueueInterface; use InvalidArgumentException; -use Psr\Log\LoggerInterface; use Symfony\Component\Config\FileLocatorInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -61,13 +60,11 @@ final class DependenciesCommand extends Command * @param ProcessBuilderInterface $processBuilder creates analyzer and upgrade processes * @param ProcessQueueInterface $processQueue executes queued processes * @param FileLocatorInterface $fileLocator resolves the dependency analyser configuration - * @param LoggerInterface $logger writes command feedback */ public function __construct( private readonly ProcessBuilderInterface $processBuilder, private readonly ProcessQueueInterface $processQueue, private readonly FileLocatorInterface $fileLocator, - private readonly LoggerInterface $logger, ) { return parent::__construct(); } @@ -150,11 +147,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int ); } - if (! $jsonOutput) { - $this->logger->info('Running dependency analysis...', [ - 'input' => $input, - ]); - } + $this->log('Running dependency analysis...', $input); $this->processQueue->add( process: $this->getComposerDependencyAnalyserCommand($input), diff --git a/src/Console/Command/DocsCommand.php b/src/Console/Command/DocsCommand.php index a8118afb98..e718cbebff 100644 --- a/src/Console/Command/DocsCommand.php +++ b/src/Console/Command/DocsCommand.php @@ -31,7 +31,6 @@ use FastForward\DevTools\Path\ManagedWorkspace; use FastForward\DevTools\Project\ProjectCapabilities; use FastForward\DevTools\Project\ProjectCapabilitiesResolverInterface; -use Psr\Log\LoggerInterface; use Psr\Log\LogLevel; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -71,11 +70,10 @@ final class DocsCommand extends Command * * @param ProcessBuilderInterface $processBuilder the process builder for executing phpDocumentor * @param ProcessQueueInterface $processQueue the process queue for managing execution - * @param Environment $renderer + * @param Environment $renderer renders phpDocumentor configuration templates * @param FilesystemInterface $filesystem the filesystem for handling file operations * @param ComposerJsonInterface $composer the composer.json handler for accessing project metadata * @param ProjectCapabilitiesResolverInterface $projectCapabilitiesResolver the project capability resolver - * @param LoggerInterface $logger the output-aware logger */ public function __construct( private readonly ProcessBuilderInterface $processBuilder, @@ -84,7 +82,6 @@ public function __construct( private readonly FilesystemInterface $filesystem, private readonly ComposerJsonInterface $composer, private readonly ProjectCapabilitiesResolverInterface $projectCapabilitiesResolver, - private readonly LoggerInterface $logger, ) { parent::__construct(); } @@ -130,12 +127,10 @@ protected function configure(): void } /** - * Generates the HTML API documentation for the configured source tree. + * Generates API documentation for the configured project surface. * - * @param InputInterface $input the input details for the command - * @param OutputInterface $output the output mechanism for logging - * - * @return int the final execution status code + * @param InputInterface $input + * @param OutputInterface $output */ protected function execute(InputInterface $input, OutputInterface $output): int { @@ -155,9 +150,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $template = DevToolsPathResolver::getPreferredVendorPath(self::DEFAULT_TEMPLATE); } - $this->logger->info('Generating API documentation...', [ - 'input' => $input, - ]); + $this->log('Generating API documentation...', $input); if ( ! $projectCapabilities->hasGuideDirectory() diff --git a/src/Console/Command/FundingCommand.php b/src/Console/Command/FundingCommand.php index 966e487f86..088e4560d0 100644 --- a/src/Console/Command/FundingCommand.php +++ b/src/Console/Command/FundingCommand.php @@ -28,7 +28,7 @@ use FastForward\DevTools\Process\ProcessBuilderInterface; use FastForward\DevTools\Process\ProcessQueueInterface; use FastForward\DevTools\Resource\FileDiffer; -use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -60,7 +60,6 @@ final class FundingCommand extends Command * @param FileDiffer $fileDiffer the differ used to summarize managed-file drift * @param ProcessBuilderInterface $processBuilder the process builder used to normalize composer.json after updates * @param ProcessQueueInterface $processQueue the process queue used to execute composer normalize - * @param LoggerInterface $logger the output-aware logger * @param SymfonyStyle $io the input/output service used to interact with the user */ public function __construct( @@ -71,7 +70,6 @@ public function __construct( private readonly FileDiffer $fileDiffer, private readonly ProcessBuilderInterface $processBuilder, private readonly ProcessQueueInterface $processQueue, - private readonly LoggerInterface $logger, private readonly SymfonyStyle $io, ) { parent::__construct(); @@ -133,18 +131,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int $check = (bool) $input->getOption('check'); $interactive = (bool) $input->getOption('interactive'); - $this->logger->info('Synchronizing funding metadata...', [ - 'input' => $input, - ]); + $this->log('Synchronizing funding metadata...', $input); if (! $this->filesystem->exists($composerFile)) { - $this->notice( + $this->log( 'Composer file {composer_file} does not exist. Skipping funding synchronization.', $input, [ 'composer_file' => $composerFile, 'funding_file' => $fundingFile, ], + LogLevel::NOTICE, ); return $this->success( @@ -154,7 +151,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'composer_file' => $composerFile, 'funding_file' => $fundingFile, ], - 'notice', + LogLevel::NOTICE, ); } @@ -228,25 +225,22 @@ private function handleComposerFile( \sprintf('Updating managed file %s from generated funding metadata synchronization.', $composerFile), ); - $this->logger->notice( - $comparison->getSummary(), - [ - 'input' => $input, - 'composer_file' => $composerFile, - ], - ); + $this->log($comparison->getSummary(), $input, [ + 'composer_file' => $composerFile, + ], LogLevel::NOTICE); if ($comparison->isChanged()) { $consoleDiff = $this->fileDiffer->formatForConsole($comparison->getDiff(), $output->isDecorated()); if (null !== $consoleDiff) { - $this->logger->notice( + $this->log( $consoleDiff, + $input, [ - 'input' => $input, 'composer_file' => $composerFile, 'diff' => $comparison->getDiff(), ], + LogLevel::NOTICE, ); } } @@ -279,14 +273,14 @@ private function handleComposerFile( [ 'composer_file' => $composerFile, ], - 'notice', + LogLevel::NOTICE, ); } if ($interactive && $input->isInteractive() && ! $this->shouldWriteManagedFile($composerFile)) { - $this->notice('Skipped updating {composer_file}.', $input, [ + $this->log('Skipped updating {composer_file}.', $input, [ 'composer_file' => $composerFile, - ]); + ], LogLevel::NOTICE); return $this->success( 'Funding synchronization was skipped for {composer_file}.', @@ -294,7 +288,7 @@ private function handleComposerFile( [ 'composer_file' => $composerFile, ], - 'notice', + LogLevel::NOTICE, ); } @@ -345,12 +339,13 @@ private function handleFundingFile( OutputInterface $output, ): int { if (null === $updatedFundingContents && null === $currentFundingContents) { - $this->notice( + $this->log( 'No supported funding metadata found. Skipping .github/FUNDING.yml synchronization.', $input, [ 'funding_file' => $fundingFile, ], + LogLevel::NOTICE, ); return $this->success( @@ -359,7 +354,7 @@ private function handleFundingFile( [ 'funding_file' => $fundingFile, ], - 'notice', + LogLevel::NOTICE, ); } @@ -386,22 +381,22 @@ private function handleFundingFile( : \sprintf('Updating managed file %s from generated funding metadata synchronization.', $fundingFile), ); - $this->logger->notice($comparison->getSummary(), [ - 'input' => $input, + $this->log($comparison->getSummary(), $input, [ 'funding_file' => $fundingFile, - ]); + ], LogLevel::NOTICE); if ($comparison->isChanged()) { $consoleDiff = $this->fileDiffer->formatForConsole($comparison->getDiff(), $output->isDecorated()); if (null !== $consoleDiff) { - $this->logger->notice( + $this->log( $consoleDiff, + $input, [ - 'input' => $input, 'funding_file' => $fundingFile, 'diff' => $comparison->getDiff(), ], + LogLevel::NOTICE, ); } } @@ -434,14 +429,14 @@ private function handleFundingFile( [ 'funding_file' => $fundingFile, ], - 'notice', + LogLevel::NOTICE, ); } if ($interactive && $input->isInteractive() && ! $this->shouldWriteManagedFile($fundingFile)) { - $this->notice('Skipped updating {funding_file}.', $input, [ + $this->log('Skipped updating {funding_file}.', $input, [ 'funding_file' => $fundingFile, - ]); + ], LogLevel::NOTICE); return $this->success( 'Funding synchronization was skipped for {funding_file}.', @@ -449,7 +444,7 @@ private function handleFundingFile( [ 'funding_file' => $fundingFile, ], - 'notice', + LogLevel::NOTICE, ); } diff --git a/src/Console/Command/GitAttributesCommand.php b/src/Console/Command/GitAttributesCommand.php index fe4fec7f99..ebd61497fb 100644 --- a/src/Console/Command/GitAttributesCommand.php +++ b/src/Console/Command/GitAttributesCommand.php @@ -30,7 +30,7 @@ use FastForward\DevTools\GitAttributes\ReaderInterface; use FastForward\DevTools\GitAttributes\WriterInterface; use FastForward\DevTools\Resource\FileDiffer; -use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -77,7 +77,6 @@ final class GitAttributesCommand extends Command * @param FilesystemInterface $filesystem the filesystem component * @param ComposerJsonInterface $composer the composer.json accessor * @param FileDiffer $fileDiffer the file differ used to summarize synchronization changes - * @param LoggerInterface $logger the output-aware logger * @param SymfonyStyle $io the input/output service used to interact with the user */ public function __construct( @@ -90,7 +89,6 @@ public function __construct( private readonly ComposerJsonInterface $composer, private readonly FilesystemInterface $filesystem, private readonly FileDiffer $fileDiffer, - private readonly LoggerInterface $logger, private readonly SymfonyStyle $io, ) { parent::__construct(); @@ -135,9 +133,7 @@ protected function configure(): void */ protected function execute(InputInterface $input, OutputInterface $output): int { - $this->logger->info('Synchronizing .gitattributes export-ignore rules...', [ - 'input' => $input, - ]); + $this->log('Synchronizing .gitattributes export-ignore rules...', $input); $dryRun = (bool) $input->getOption('dry-run'); $check = (bool) $input->getOption('check'); $interactive = (bool) $input->getOption('interactive'); @@ -154,12 +150,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int $entries = [...$existingFolders, ...$existingFiles]; if ([] === $entries) { - $this->notice('No candidate paths found in repository. Skipping .gitattributes sync.', $input); + $this->log( + 'No candidate paths found in repository. Skipping .gitattributes sync.', + $input, + logLevel: LogLevel::NOTICE, + ); return $this->success( 'No .gitattributes synchronization changes were required.', $input, - logLevel: 'notice', + logLevel: LogLevel::NOTICE, ); } @@ -175,25 +175,22 @@ protected function execute(InputInterface $input, OutputInterface $output): int \sprintf('Updating managed file %s from generated .gitattributes synchronization.', $gitattributesPath), ); - $this->logger->notice( - $comparison->getSummary(), - [ - 'input' => $input, - 'gitattributes_path' => $gitattributesPath, - ], - ); + $this->log($comparison->getSummary(), $input, [ + 'gitattributes_path' => $gitattributesPath, + ], LogLevel::NOTICE); if ($comparison->isChanged()) { $consoleDiff = $this->fileDiffer->formatForConsole($comparison->getDiff(), $output->isDecorated()); if (null !== $consoleDiff) { - $this->logger->notice( + $this->log( $consoleDiff, + $input, [ - 'input' => $input, 'gitattributes_path' => $gitattributesPath, 'diff' => $comparison->getDiff(), ], + LogLevel::NOTICE, ); } } @@ -220,17 +217,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int [ 'gitattributes_path' => $gitattributesPath, ], - 'notice', + LogLevel::NOTICE, ); } if ($interactive && $input->isInteractive() && ! $this->shouldWriteGitAttributes($gitattributesPath)) { - $this->notice( + $this->log( 'Skipped updating {gitattributes_path}.', $input, [ 'gitattributes_path' => $gitattributesPath, ], + LogLevel::NOTICE, ); return $this->success( @@ -239,7 +237,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int [ 'gitattributes_path' => $gitattributesPath, ], - 'notice', + LogLevel::NOTICE, ); } diff --git a/src/Console/Command/GitHooksCommand.php b/src/Console/Command/GitHooksCommand.php index 9b08e94ec5..aa903c8f25 100644 --- a/src/Console/Command/GitHooksCommand.php +++ b/src/Console/Command/GitHooksCommand.php @@ -26,7 +26,7 @@ use FastForward\DevTools\GitHooks\HookContentRenderer; use FastForward\DevTools\Resource\FileDiff; use FastForward\DevTools\Resource\FileDiffer; -use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel; use Symfony\Component\Config\FileLocatorInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -60,7 +60,6 @@ final class GitHooksCommand extends Command * @param FinderFactoryInterface $finderFactory the factory used to create finders for hook files * @param HookContentRenderer $hookContentRenderer renders packaged hooks with runtime-specific placeholders * @param FileDiffer $fileDiffer the file differ used to summarize synchronization changes - * @param LoggerInterface $logger the output-aware logger * @param SymfonyStyle $io the input/output service used to interact with the user */ public function __construct( @@ -69,7 +68,6 @@ public function __construct( private readonly FinderFactoryInterface $finderFactory, private readonly HookContentRenderer $hookContentRenderer, private readonly FileDiffer $fileDiffer, - private readonly LoggerInterface $logger, private readonly SymfonyStyle $io, ) { parent::__construct(); @@ -152,13 +150,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int $hookPath = Path::join($targetPath, $file->getRelativePathname()); if (! $overwrite && ! $dryRun && ! $check && ! $interactive && $this->filesystem->exists($hookPath)) { - $this->notice( + $this->log( 'Skipped existing {hook_name} hook.', $input, [ 'hook_name' => $file->getFilename(), 'hook_path' => $hookPath, ], + LogLevel::NOTICE, ); continue; @@ -169,27 +168,29 @@ protected function execute(InputInterface $input, OutputInterface $output): int ? $this->fileDiffer->diff($sourcePath, $hookPath) : $this->compareRenderedHookContents($sourcePath, $hookPath, $renderedSourceContents); - $this->logger->notice( + $this->log( $comparison->getSummary(), + $input, [ - 'input' => $input, 'hook_name' => $file->getFilename(), 'hook_path' => $hookPath, ], + LogLevel::NOTICE, ); if ($comparison->isChanged()) { $consoleDiff = $this->fileDiffer->formatForConsole($comparison->getDiff(), $output->isDecorated()); if (null !== $consoleDiff) { - $this->logger->notice( + $this->log( $consoleDiff, + $input, [ - 'input' => $input, 'hook_name' => $file->getFilename(), 'hook_path' => $hookPath, 'diff' => $comparison->getDiff(), ], + LogLevel::NOTICE, ); } } @@ -209,13 +210,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int } if ($interactive && $input->isInteractive() && ! $this->shouldReplaceHook($hookPath)) { - $this->notice( + $this->log( 'Skipped replacing {hook_path}.', $input, [ 'hook_name' => $file->getFilename(), 'hook_path' => $hookPath, ], + LogLevel::NOTICE, ); continue; @@ -325,16 +327,15 @@ private function installHook( return true; } catch (IOExceptionInterface $ioException) { - $this->logger->error( + $this->failure( 'Failed to install {hook_name} hook automatically. Remove or unlock {hook_path} and rerun git-hooks.', + $input, [ - 'input' => $input, 'hook_name' => $this->filesystem->getBasename($hookPath), 'hook_path' => $hookPath, 'error' => $ioException->getMessage(), - 'file' => $ioException->getPath() ?? $hookPath, - 'line' => null, ], + $ioException->getPath() ?? $hookPath, ); return false; diff --git a/src/Console/Command/GitIgnoreCommand.php b/src/Console/Command/GitIgnoreCommand.php index 57bc7072ec..54d4199990 100644 --- a/src/Console/Command/GitIgnoreCommand.php +++ b/src/Console/Command/GitIgnoreCommand.php @@ -26,7 +26,6 @@ use FastForward\DevTools\GitIgnore\WriterInterface; use FastForward\DevTools\Path\DevToolsPathResolver; use FastForward\DevTools\Resource\FileDiffer; -use Psr\Log\LoggerInterface; use Psr\Log\LogLevel; use Symfony\Component\Config\FileLocatorInterface; use Symfony\Component\Console\Attribute\AsCommand; @@ -69,7 +68,6 @@ final class GitIgnoreCommand extends Command * @param WriterInterface|null $writer the writer component * @param FileLocatorInterface $fileLocator the file locator * @param FileDiffer $fileDiffer the file differ used to summarize synchronization changes - * @param LoggerInterface $logger the output-aware logger * @param SymfonyStyle $io the input/output service used to interact with the user */ public function __construct( @@ -78,7 +76,6 @@ public function __construct( private readonly WriterInterface $writer, private readonly FileLocatorInterface $fileLocator, private readonly FileDiffer $fileDiffer, - private readonly LoggerInterface $logger, private readonly SymfonyStyle $io, ) { parent::__construct(); @@ -138,9 +135,7 @@ protected function configure(): void */ protected function execute(InputInterface $input, OutputInterface $output): int { - $this->logger->info('Merging .gitignore files...', [ - 'input' => $input, - ]); + $this->log('Merging .gitignore files...', $input); $sourcePath = $input->getOption('source'); $targetPath = $input->getOption('target'); @@ -160,21 +155,22 @@ protected function execute(InputInterface $input, OutputInterface $output): int \sprintf('Updating managed file %s from generated .gitignore synchronization.', $merged->path()), ); - $this->notice($comparison->getSummary(), $input, [ + $this->log($comparison->getSummary(), $input, [ 'target_path' => $merged->path(), - ]); + ], LogLevel::NOTICE); if ($comparison->isChanged()) { $consoleDiff = $this->fileDiffer->formatForConsole($comparison->getDiff(), $output->isDecorated()); if (null !== $consoleDiff) { - $this->notice( + $this->log( $consoleDiff, $input, [ 'target_path' => $merged->path(), 'diff' => $comparison->getDiff(), ], + LogLevel::NOTICE, ); } } diff --git a/src/Console/Command/LicenseCommand.php b/src/Console/Command/LicenseCommand.php index 94314345b6..f96940f28f 100644 --- a/src/Console/Command/LicenseCommand.php +++ b/src/Console/Command/LicenseCommand.php @@ -24,7 +24,7 @@ use FastForward\DevTools\Filesystem\FilesystemInterface; use FastForward\DevTools\License\GeneratorInterface; use FastForward\DevTools\Resource\FileDiffer; -use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -55,14 +55,12 @@ final class LicenseCommand extends Command * @param GeneratorInterface $generator the generator component * @param FilesystemInterface $filesystem the filesystem component * @param FileDiffer $fileDiffer the file differ used to summarize synchronization changes - * @param LoggerInterface $logger the output-aware logger * @param SymfonyStyle $io the input/output service used to interact with the user */ public function __construct( private readonly GeneratorInterface $generator, private readonly FilesystemInterface $filesystem, private readonly FileDiffer $fileDiffer, - private readonly LoggerInterface $logger, private readonly SymfonyStyle $io, ) { parent::__construct(); @@ -122,12 +120,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int $generatedContent = $this->generator->generateContent(); if (null === $generatedContent) { - $this->notice( + $this->log( 'No supported license found in composer.json or license is unsupported. Skipping LICENSE generation.', $input, [ 'target_path' => $targetPath, ], + LogLevel::NOTICE, ); return $this->success( @@ -136,7 +135,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int [ 'target_path' => $targetPath, ], - 'notice', + LogLevel::NOTICE, ); } @@ -150,22 +149,22 @@ protected function execute(InputInterface $input, OutputInterface $output): int : \sprintf('Updating managed file %s from generated LICENSE content.', $targetPath), ); - $this->logger->notice($comparison->getSummary(), [ - 'input' => $input, + $this->log($comparison->getSummary(), $input, [ 'target_path' => $targetPath, - ]); + ], LogLevel::NOTICE); if ($comparison->isChanged()) { $consoleDiff = $this->fileDiffer->formatForConsole($comparison->getDiff(), $output->isDecorated()); if (null !== $consoleDiff) { - $this->logger->notice( + $this->log( $consoleDiff, + $input, [ - 'input' => $input, 'target_path' => $targetPath, 'diff' => $comparison->getDiff(), ], + LogLevel::NOTICE, ); } } @@ -198,14 +197,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int [ 'target_path' => $targetPath, ], - 'notice', + LogLevel::NOTICE, ); } if ($interactive && $input->isInteractive() && ! $this->shouldWriteLicense($targetPath)) { - $this->notice('Skipped updating {target_path}.', $input, [ + $this->log('Skipped updating {target_path}.', $input, [ 'target_path' => $targetPath, - ]); + ], LogLevel::NOTICE); return $this->success( 'LICENSE generation was skipped.', @@ -213,7 +212,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int [ 'target_path' => $targetPath, ], - 'notice', + LogLevel::NOTICE, ); } diff --git a/src/Console/Command/MetricsCommand.php b/src/Console/Command/MetricsCommand.php index 972b47358b..97e101996e 100644 --- a/src/Console/Command/MetricsCommand.php +++ b/src/Console/Command/MetricsCommand.php @@ -25,7 +25,6 @@ use FastForward\DevTools\Process\ProcessBuilderInterface; use FastForward\DevTools\Process\ProcessQueueInterface; use FastForward\DevTools\Path\ManagedWorkspace; -use Psr\Log\LoggerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -77,12 +76,10 @@ final class MetricsCommand extends Command /** * @param ProcessBuilderInterface $processBuilder the builder used to assemble the PhpMetrics process * @param ProcessQueueInterface $processQueue the queue used to execute the PhpMetrics process - * @param LoggerInterface $logger the output-aware logger */ public function __construct( private readonly ProcessBuilderInterface $processBuilder, private readonly ProcessQueueInterface $processQueue, - private readonly LoggerInterface $logger, ) { parent::__construct(); } @@ -135,9 +132,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $exclude = (string) $input->getOption('exclude'); $junit = $input->getOption('junit'); - $this->logger->info('Running code metrics analysis...', [ - 'input' => $input, - ]); + $this->log('Running code metrics analysis...', $input); $processBuilder = $this->processBuilder ->withArgument('--ansi') diff --git a/src/Console/Command/PhpDocCommand.php b/src/Console/Command/PhpDocCommand.php index 52d5627799..62270dc7c4 100644 --- a/src/Console/Command/PhpDocCommand.php +++ b/src/Console/Command/PhpDocCommand.php @@ -29,7 +29,7 @@ use FastForward\DevTools\Process\ProcessQueueInterface; use FastForward\DevTools\Path\ManagedWorkspace; use Psr\Clock\ClockInterface; -use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel; use Twig\Environment; use Throwable; use FastForward\DevTools\Rector\AddMissingMethodPhpDocRector; @@ -82,7 +82,6 @@ final class PhpDocCommand extends Command * @param ComposerJsonInterface $composer * @param Environment $renderer * @param ClockInterface $clock - * @param LoggerInterface $logger the output-aware logger */ public function __construct( private readonly ProcessBuilderInterface $processBuilder, @@ -92,7 +91,6 @@ public function __construct( private readonly FilesystemInterface $filesystem, private readonly Environment $renderer, private readonly ClockInterface $clock, - private readonly LoggerInterface $logger, ) { parent::__construct(); } @@ -152,11 +150,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int $progress = ! $jsonOutput && (bool) $input->getOption('progress'); $cacheEnabled = $this->isCacheEnabled($input); - $this->logger->info('Checking and fixing PHPDocs...', [ - 'input' => $input, - ]); + $this->log('Checking and fixing PHPDocs...', $input); - $this->ensureDocHeaderExists(); + $this->ensureDocHeaderExists($input); $processBuilder = $this->processBuilder ->withArgument('--ansi') @@ -231,9 +227,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int * The method MUST query the local filesystem. If the file is missing, it SHOULD copy * the tool template into the root folder. * + * @param InputInterface $input the originating command input + * * @return void */ - private function ensureDocHeaderExists(): void + private function ensureDocHeaderExists(InputInterface $input): void { $support = $this->composer->getSupport(); @@ -258,13 +256,15 @@ private function ensureDocHeaderExists(): void try { $this->filesystem->dumpFile(self::FILENAME, $docHeader); } catch (Throwable) { - $this->logger->warning( - 'Skipping .docheader creation because the destination file could not be written.' + $this->log( + 'Skipping .docheader creation because the destination file could not be written.', + $input, + logLevel: LogLevel::WARNING, ); return; } - $this->logger->info('Created .docheader from repository template.'); + $this->log('Created .docheader from repository template.', $input); } } diff --git a/src/Console/Command/RefactorCommand.php b/src/Console/Command/RefactorCommand.php index f2a1d51bbc..6da0bdd9cb 100644 --- a/src/Console/Command/RefactorCommand.php +++ b/src/Console/Command/RefactorCommand.php @@ -24,7 +24,6 @@ use FastForward\DevTools\Path\DevToolsPathResolver; use FastForward\DevTools\Process\ProcessBuilderInterface; use FastForward\DevTools\Process\ProcessQueueInterface; -use Psr\Log\LoggerInterface; use Symfony\Component\Config\FileLocatorInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -58,13 +57,11 @@ final class RefactorCommand extends Command * @param FileLocatorInterface $fileLocator the file locator * @param ProcessBuilderInterface $processBuilder the process builder * @param ProcessQueueInterface $processQueue the process queue - * @param LoggerInterface $logger the output-aware logger */ public function __construct( private readonly FileLocatorInterface $fileLocator, private readonly ProcessBuilderInterface $processBuilder, private readonly ProcessQueueInterface $processQueue, - private readonly LoggerInterface $logger, ) { parent::__construct(); } @@ -120,9 +117,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $fix = (bool) $input->getOption('fix'); $progress = ! $jsonOutput && (bool) $input->getOption('progress'); - $this->logger->info('Running Rector for code refactoring...', [ - 'input' => $input, - ]); + $this->log('Running Rector for code refactoring...', $input); $processBuilder = $this->processBuilder ->withArgument('--ansi') diff --git a/src/Console/Command/ReportsCommand.php b/src/Console/Command/ReportsCommand.php index 39daed3c75..d9658cb59e 100644 --- a/src/Console/Command/ReportsCommand.php +++ b/src/Console/Command/ReportsCommand.php @@ -26,7 +26,6 @@ use FastForward\DevTools\Process\ProcessBuilderInterface; use FastForward\DevTools\Process\ProcessQueueInterface; use FastForward\DevTools\Path\ManagedWorkspace; -use Psr\Log\LoggerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -35,8 +34,7 @@ use Symfony\Component\Console\Output\OutputInterface; /** - * Coordinates the generation of Fast Forward documentation frontpage and related reports. - * This class MUST NOT be overridden and SHALL securely combine docs and testing commands. + * Coordinates documentation, coverage, and metrics report generation. */ #[AsCommand( name: 'standards:reports', @@ -54,18 +52,16 @@ final class ReportsCommand extends Command * * @param ProcessBuilderInterface $processBuilder the builder instance used to construct execution processes * @param ProcessQueueInterface $processQueue the execution queue mechanism for running sub-processes - * @param LoggerInterface $logger the output-aware logger */ public function __construct( private readonly ProcessBuilderInterface $processBuilder, private readonly ProcessQueueInterface $processQueue, - private readonly LoggerInterface $logger, ) { parent::__construct(); } /** - * @return void + * Configures the report generation options. */ protected function configure(): void { @@ -129,9 +125,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $coveragePath = (string) $input->getOption('coverage'); $metricsPath = (string) $input->getOption('metrics'); - $this->logger->info('Generating frontpage for Fast Forward documentation...', [ - 'input' => $input, - ]); + $this->log('Generating frontpage for Fast Forward documentation...', $input); $docsBuilder = $this->processBuilder ->withArgument('--target', $target); diff --git a/src/Console/Command/SelfUpdateCommand.php b/src/Console/Command/SelfUpdateCommand.php index e7166de71d..de9ab96b04 100644 --- a/src/Console/Command/SelfUpdateCommand.php +++ b/src/Console/Command/SelfUpdateCommand.php @@ -23,7 +23,6 @@ use FastForward\DevTools\Reflection\ClassReflection; use FastForward\DevTools\SelfUpdate\SelfUpdateRunnerInterface; use FastForward\DevTools\SelfUpdate\SelfUpdateScopeResolverInterface; -use Psr\Log\LoggerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -44,12 +43,10 @@ final class SelfUpdateCommand extends Command /** * @param SelfUpdateRunnerInterface $selfUpdateRunner the runner that executes Composer's update command * @param SelfUpdateScopeResolverInterface $scopeResolver resolves whether the active binary is globally installed - * @param LoggerInterface $logger the output-aware logger */ public function __construct( private readonly SelfUpdateRunnerInterface $selfUpdateRunner, private readonly SelfUpdateScopeResolverInterface $scopeResolver, - private readonly LoggerInterface $logger, ) { parent::__construct(); } @@ -100,8 +97,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $global = $this->scopeResolver->isGlobalInstallation(); - $this->logger->info('Updating DevTools installation...', [ - 'input' => $input, + $this->log('Updating DevTools installation...', $input, [ 'global' => $global, ]); diff --git a/src/Console/Command/SkillsCommand.php b/src/Console/Command/SkillsCommand.php index 726bec3e82..38f3058e28 100644 --- a/src/Console/Command/SkillsCommand.php +++ b/src/Console/Command/SkillsCommand.php @@ -24,7 +24,6 @@ use FastForward\DevTools\Filesystem\FilesystemInterface; use FastForward\DevTools\Path\DevToolsPathResolver; use FastForward\DevTools\Sync\PackagedDirectorySynchronizer; -use Psr\Log\LoggerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -65,12 +64,10 @@ final class SkillsCommand extends Command * @param FilesystemInterface $filesystem filesystem used to resolve * and manage the skills * directory structure - * @param LoggerInterface $logger logger used for command feedback */ public function __construct( private readonly PackagedDirectorySynchronizer $synchronizer, private readonly FilesystemInterface $filesystem, - private readonly LoggerInterface $logger, ) { parent::__construct(); } @@ -112,7 +109,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $packageSkillsPath = DevToolsPathResolver::getPackagePath(self::SKILLS_DIRECTORY); $skillsDir = $this->filesystem->getAbsolutePath(self::SKILLS_DIRECTORY); - $this->logger->info('Starting skills synchronization...'); + $this->log('Starting skills synchronization...', $input); if (! $this->filesystem->exists($packageSkillsPath)) { return $this->failure( @@ -131,7 +128,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if (! $this->filesystem->exists($skillsDir)) { $this->filesystem->mkdir($skillsDir); $directoryCreated = true; - $this->logger->info('Created .agents/skills directory.'); + $this->log('Created .agents/skills directory.', $input); } $result = $this->synchronizer->synchronize($skillsDir, $packageSkillsPath, self::SKILLS_DIRECTORY); diff --git a/src/Console/Command/StandardsCommand.php b/src/Console/Command/StandardsCommand.php index bef10eedc3..d1272f5d48 100644 --- a/src/Console/Command/StandardsCommand.php +++ b/src/Console/Command/StandardsCommand.php @@ -26,7 +26,6 @@ use FastForward\DevTools\Path\ManagedWorkspace; use FastForward\DevTools\Process\ProcessBuilderInterface; use FastForward\DevTools\Process\ProcessQueueInterface; -use Psr\Log\LoggerInterface; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -52,12 +51,10 @@ final class StandardsCommand extends Command /** * @param ProcessBuilderInterface $processBuilder * @param ProcessQueueInterface $processQueue - * @param LoggerInterface $logger */ public function __construct( private readonly ProcessBuilderInterface $processBuilder, private readonly ProcessQueueInterface $processQueue, - private readonly LoggerInterface $logger, ) { parent::__construct(); } @@ -109,9 +106,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $commands = []; $fix = (bool) $input->getOption('fix'); - $this->logger->info('Running code standards checks...', [ - 'input' => $input, - ]); + $this->log('Running code standards checks...', $input); foreach (['refactor', 'phpdoc', 'code-style', 'reports'] as $command) { $commands[] = $command; diff --git a/src/Console/Command/SyncCommand.php b/src/Console/Command/SyncCommand.php index 85fc82af78..4f4ae847a7 100644 --- a/src/Console/Command/SyncCommand.php +++ b/src/Console/Command/SyncCommand.php @@ -24,7 +24,7 @@ use FastForward\DevTools\Path\DevToolsPathResolver; use FastForward\DevTools\Process\ProcessBuilderInterface; use FastForward\DevTools\Process\ProcessQueueInterface; -use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -48,12 +48,10 @@ final class SyncCommand extends Command /** * @param ProcessBuilderInterface $processBuilder * @param ProcessQueueInterface $processQueue - * @param LoggerInterface $logger */ public function __construct( private readonly ProcessBuilderInterface $processBuilder, private readonly ProcessQueueInterface $processQueue, - private readonly LoggerInterface $logger, ) { parent::__construct(); } @@ -112,9 +110,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int ]; $allowDetached = ! $dryRun && ! $check && ! $interactive; - $this->logger->info('Starting dev-tools synchronization...', [ - 'input' => $input, - ]); + $this->log('Starting dev-tools synchronization...', $input); $this->queueDevToolsCommand(['update-composer-json', ...$modeArguments], false, $jsonOutput, $prettyJsonOutput); $this->queueDevToolsCommand(['funding', ...$modeArguments], false, $jsonOutput, $prettyJsonOutput); @@ -162,11 +158,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int ); if ($dryRun || $check || $interactive) { - $this->logger->warning( + $this->log( 'Skipping wiki, skills, and agents during preview/check modes because they do not yet expose non-destructive verification.', - [ - 'input' => $input, - ], + $input, + logLevel: LogLevel::WARNING, ); } else { $this->queueDevToolsCommand(['wiki', '--init'], true, $jsonOutput, $prettyJsonOutput); diff --git a/src/Console/Command/TestsCommand.php b/src/Console/Command/TestsCommand.php index 782cf9c280..bc034e5eab 100644 --- a/src/Console/Command/TestsCommand.php +++ b/src/Console/Command/TestsCommand.php @@ -19,6 +19,7 @@ namespace FastForward\DevTools\Console\Command; +use JsonException; use FastForward\DevTools\Console\Command\Traits\LogsCommandResults; use FastForward\DevTools\Console\Input\HasCacheOption; use FastForward\DevTools\Console\Input\HasJsonOption; @@ -32,7 +33,6 @@ use FastForward\DevTools\Path\ManagedWorkspace; use FastForward\DevTools\Project\ProjectCapabilitiesResolverInterface; use InvalidArgumentException; -use Psr\Log\LoggerInterface; use Psr\Log\LogLevel; use RuntimeException; use Symfony\Component\Config\FileLocatorInterface; @@ -43,8 +43,11 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Process\Process; use function is_numeric; +use function Safe\json_decode; +use function Safe\preg_match; /** * Facilitates the execution of the PHPUnit testing framework. @@ -57,6 +60,12 @@ final class TestsCommand extends Command use HasJsonOption; use LogsCommandResults; + private const string AGENT_ENVIRONMENT_VARIABLE = 'AI_AGENT'; + + private const string AGENT_ENVIRONMENT_VALUE = 'fast-forward/dev-tools'; + + private const string PROCESS_LABEL = 'Running PHPUnit Tests'; + /** * @var string identifies the local configuration file for PHPUnit processes */ @@ -71,7 +80,6 @@ final class TestsCommand extends Command * @param ProcessBuilderInterface $processBuilder the builder used to assemble the PHPUnit process * @param ProcessQueueInterface $processQueue the queue used to execute PHPUnit * @param ProjectCapabilitiesResolverInterface $projectCapabilitiesResolver the project capability resolver - * @param LoggerInterface $logger the output-aware logger */ public function __construct( private readonly CoverageSummaryLoaderInterface $coverageSummaryLoader, @@ -82,7 +90,6 @@ public function __construct( private readonly ProcessBuilderInterface $processBuilder, private readonly ProcessQueueInterface $processQueue, private readonly ProjectCapabilitiesResolverInterface $projectCapabilitiesResolver, - private readonly LoggerInterface $logger, ) { parent::__construct(); } @@ -160,14 +167,11 @@ protected function configure(): void */ protected function execute(InputInterface $input, OutputInterface $output): int { - $jsonOutput = $this->isJsonOutput($input); - $processOutput = $jsonOutput ? new BufferedOutput() : $output; + $structuredOutput = $this->isJsonOutput($input); + $processOutput = $structuredOutput ? new BufferedOutput() : $output; $cacheEnabled = $this->isCacheEnabled($input); - $this->getLogger() - ->info('Running PHPUnit tests...', [ - 'input' => $input, - ]); + $this->log('Running PHPUnit tests...', $input); try { $minimumCoverage = $this->resolveMinimumCoverage($input); @@ -206,11 +210,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int ->withArgument('--display-incomplete') ->withArgument('--display-skipped'); - if (! $input->getOption('progress') || $jsonOutput) { + if (! $input->getOption('progress') || $structuredOutput) { $processBuilder = $processBuilder->withArgument('--no-progress'); } - if (! $jsonOutput) { + if (! $structuredOutput) { $processBuilder = $processBuilder->withArgument('--colors=always'); } @@ -232,29 +236,25 @@ protected function execute(InputInterface $input, OutputInterface $output): int $processBuilder = $processBuilder->withArgument('--filter', $input->getOption('filter')); } - $this->processQueue->add( - process: $processBuilder - ->withArgument($input->getArgument('path')) - ->build([DevToolsPathResolver::getPreferredToolBinaryPath('phpunit')]), - label: 'Running PHPUnit Tests', - ); + $process = $processBuilder + ->withArgument($input->getArgument('path')) + ->build([DevToolsPathResolver::getPreferredToolBinaryPath('phpunit')]); + + if ($structuredOutput) { + $this->forceAgentReporter($process); + } + + $this->processQueue->add(process: $process, label: self::PROCESS_LABEL); $result = $this->processQueue->run($processOutput); + $processResultContext = $this->resolveProcessResultContext($processOutput, $result, $structuredOutput); if (self::SUCCESS !== $result || null === $minimumCoverage || null === $coverageReportPath) { if (self::SUCCESS === $result) { - return $this->success( - 'PHPUnit tests completed successfully.', - $input, - [ - 'output' => $processOutput, - ], - ); + return $this->success('PHPUnit tests completed successfully.', $input, $processResultContext); } - return $this->failure('PHPUnit tests failed.', $input, [ - 'output' => $processOutput, - ]); + return $this->failure('PHPUnit tests failed.', $input, $processResultContext); } [$validationResult, $message, $coverageContext] = $this->validateMinimumCoverage( @@ -262,17 +262,184 @@ protected function execute(InputInterface $input, OutputInterface $output): int $minimumCoverage, ); + if ($structuredOutput) { + $processResultContext = $this->withStructuredCoverageValidationContext( + $processResultContext, + $coverageContext, + $minimumCoverage, + $validationResult, + $message, + ); + } + if (self::SUCCESS === $validationResult) { - return $this->success($message, $input, [ - 'output' => $processOutput, - ...$coverageContext, - ]); + return $this->success($message, $input, [...$processResultContext, ...$coverageContext]); + } + + return $this->failure($message, $input, [...$processResultContext, ...$coverageContext]); + } + + /** + * Builds structured context for the executed PHPUnit process. + * + * @param OutputInterface $processOutput the output sink used while the process ran + * @param int $exitCode the exit code returned by the process queue + * @param bool $structuredOutput whether the command captured subprocess output for structured logging + * + * @return array|OutputInterface> + */ + private function resolveProcessResultContext( + OutputInterface $processOutput, + int $exitCode, + bool $structuredOutput, + ): array { + if ($structuredOutput) { + return [ + 'output' => $this->resolveStructuredProcessResultPayload($processOutput, $exitCode), + ]; } - return $this->failure($message, $input, [ + return [ 'output' => $processOutput, + ]; + } + + /** + * Forces the PHPUnit subprocess to expose the agent reporter payload. + * + * @param Process $process the configured PHPUnit process + * + * @return void + */ + private function forceAgentReporter(Process $process): void + { + $env = $process->getEnv(); + + if (\array_key_exists(self::AGENT_ENVIRONMENT_VARIABLE, $env) || false !== getenv( + self::AGENT_ENVIRONMENT_VARIABLE + )) { + return; + } + + // Intentionally reuse the reporter's existing agent detection path for + // structured DevTools output instead of maintaining a separate + // integration that would need to mirror the plugin behavior. + $env[self::AGENT_ENVIRONMENT_VARIABLE] = self::AGENT_ENVIRONMENT_VALUE; + $process->setEnv($env); + } + + /** + * Builds the structured payload that will be emitted for agent-oriented runs. + * + * @param OutputInterface $processOutput the output sink used while the process ran + * @param int $exitCode the exit code returned by the process queue + * + * @return array the structured process payload + */ + private function resolveStructuredProcessResultPayload(OutputInterface $processOutput, int $exitCode): array + { + $payload = [ + 'result' => self::SUCCESS === $exitCode ? 'success' : 'failure', + ]; + + if (! $processOutput instanceof BufferedOutput) { + return $payload; + } + + $rawOutput = trim($processOutput->fetch()); + + if ('' === $rawOutput) { + return $payload; + } + + [$decoded, $supplementalOutput] = $this->decodeStructuredProcessOutput($rawOutput); + + if (\is_array($decoded)) { + $payload = [ + ...$decoded, + 'result' => $payload['result'], + ]; + } + + if (null !== $supplementalOutput) { + $payload['raw_output'] = $supplementalOutput; + } + + return $payload; + } + + /** + * Appends minimum-coverage validation data to the structured PHPUnit output payload. + * + * @param array $context the command result context + * @param array $coverageContext structured coverage metrics + * @param float $minimumCoverage the required coverage percentage + * @param int $validationResult the post-PHPUnit validation status + * @param string $message the validation message + * + * @return array the enriched structured command context + */ + private function withStructuredCoverageValidationContext( + array $context, + array $coverageContext, + float $minimumCoverage, + int $validationResult, + string $message, + ): array { + if (! isset($context['output']) || ! \is_array($context['output'])) { + return $context; + } + + $payload = $context['output']; + $payload['coverage'] = [ ...$coverageContext, - ]); + 'minimum' => $minimumCoverage, + ]; + + if (self::SUCCESS !== $validationResult) { + $payload['message'] = $message; + $payload['result'] = 'failure'; + } + + $context['output'] = $payload; + + return $context; + } + + /** + * Attempts to decode structured PHPUnit output while preserving any + * non-JSON prelude that was emitted before the final reporter payload. + * + * @param string $rawOutput the captured subprocess output + * + * @return array{array|null, string|null} decoded payload and preserved supplemental output + */ + private function decodeStructuredProcessOutput(string $rawOutput): array + { + try { + $decoded = json_decode($rawOutput, true); + + return [\is_array($decoded) ? $decoded : null, null]; + } catch (JsonException) { + } + + if (1 !== preg_match('/^(?P.*?)(?P\{\s*"result".*)$/s', $rawOutput, $matches)) { + return [null, $rawOutput]; + } + + try { + $decoded = json_decode($matches['payload'], true); + } catch (JsonException) { + return [null, $rawOutput]; + } + + if (! \is_array($decoded)) { + return [null, $rawOutput]; + } + + $prefix = trim($matches['prefix']); + + return [$decoded, '' === $prefix ? null : $prefix]; } /** diff --git a/src/Console/Command/Traits/HasCommandLogger.php b/src/Console/Command/Traits/HasCommandLogger.php index e4e21c8bbd..9aadcc4d1c 100644 --- a/src/Console/Command/Traits/HasCommandLogger.php +++ b/src/Console/Command/Traits/HasCommandLogger.php @@ -19,41 +19,27 @@ namespace FastForward\DevTools\Console\Command\Traits; -use LogicException; +use FastForward\DevTools\Container\ContainerFactory; use Psr\Log\LoggerInterface; /** * Resolves the logger expected by command result helper traits. * - * The consuming command is expected to expose an initialized `$logger` - * property so reusable traits can log without coupling themselves to a - * specific constructor signature. + * The trait caches the shared logger lazily so consuming commands do not need + * to carry constructor wiring for internal logging helpers. */ trait HasCommandLogger { /** - * Returns the logger configured on the consuming command. - * - * @throws LogicException when the consuming command does not expose a valid logger property + * Caches the logger resolved for the consuming command. + */ + private ?LoggerInterface $logger = null; + + /** + * Returns the logger configured for the consuming command. */ public function getLogger(): LoggerInterface { - if (! property_exists($this, 'logger') || null === $this->logger) { - throw new LogicException(\sprintf( - 'Commands using %s MUST expose an initialized $logger property with an instance of %s.', - LogsCommandResults::class, - LoggerInterface::class, - )); - } - - if (! $this->logger instanceof LoggerInterface) { - throw new LogicException(\sprintf( - 'Commands using %s MUST expose a %s instance on the $logger property.', - LogsCommandResults::class, - LoggerInterface::class, - )); - } - - return $this->logger; + return $this->logger ??= ContainerFactory::get(LoggerInterface::class); } } diff --git a/src/Console/Command/Traits/LogsCommandResults.php b/src/Console/Command/Traits/LogsCommandResults.php index 4907e9cce1..2c6fc92e0e 100644 --- a/src/Console/Command/Traits/LogsCommandResults.php +++ b/src/Console/Command/Traits/LogsCommandResults.php @@ -35,21 +35,32 @@ trait LogsCommandResults use HasCommandLogger; /** - * Logs an informational command message at notice level. + * Logs a non-terminal command message unless structured JSON output is active. * - * @param string $message the notice message + * @param string $message the progress message * @param InputInterface $input the originating command input * @param array $context optional extra log context + * @param string $logLevel the PSR-3 log level used for the message * * @return void */ - private function notice(string $message, InputInterface $input, array $context = []): void - { + private function log( + string $message, + InputInterface $input, + array $context = [], + string $logLevel = LogLevel::INFO, + ): void { + if (method_exists($this, 'isJsonOutput') && $this->isJsonOutput($input)) { + return; + } + + $context = [ + 'input' => $input, + ...$context, + ]; + $this->getLogger() - ->notice($message, [ - 'input' => $input, - ...$context, - ]); + ->log($logLevel, $message, $context); } /** diff --git a/src/Console/Command/UpdateComposerJsonCommand.php b/src/Console/Command/UpdateComposerJsonCommand.php index 88382d82e5..38969fc7f9 100644 --- a/src/Console/Command/UpdateComposerJsonCommand.php +++ b/src/Console/Command/UpdateComposerJsonCommand.php @@ -26,7 +26,6 @@ use FastForward\DevTools\GrumPhp\ManagedConfigPathSynchronizer; use FastForward\DevTools\Path\DevToolsPathResolver; use FastForward\DevTools\Resource\FileDiffer; -use Psr\Log\LoggerInterface; use Psr\Log\LogLevel; use Symfony\Component\Config\FileLocatorInterface; use Symfony\Component\Console\Attribute\AsCommand; @@ -61,7 +60,6 @@ final class UpdateComposerJsonCommand extends Command * @param FileLocatorInterface $fileLocator the locator used to resolve packaged configuration files * @param ManagedConfigPathSynchronizer $managedConfigPathSynchronizer synchronizes managed GrumPHP metadata * @param FileDiffer $fileDiffer the file differ used to summarize synchronization changes - * @param LoggerInterface $logger the output-aware logger * @param SymfonyStyle $io the input/output service used to interact with the user */ public function __construct( @@ -70,7 +68,6 @@ public function __construct( private readonly FileLocatorInterface $fileLocator, private readonly ManagedConfigPathSynchronizer $managedConfigPathSynchronizer, private readonly FileDiffer $fileDiffer, - private readonly LoggerInterface $logger, private readonly SymfonyStyle $io, ) { parent::__construct(); @@ -144,18 +141,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int \sprintf('Updating managed file %s from generated dev-tools composer.json configuration.', $file), ); - $this->notice($comparison->getSummary(), $input, [ + $this->log($comparison->getSummary(), $input, [ 'file' => $file, - ]); + ], LogLevel::NOTICE); if ($comparison->isChanged()) { $consoleDiff = $this->fileDiffer->formatForConsole($comparison->getDiff(), $output->isDecorated()); if (null !== $consoleDiff) { - $this->notice($consoleDiff, $input, [ + $this->log($consoleDiff, $input, [ 'file' => $file, 'diff' => $comparison->getDiff(), - ]); + ], LogLevel::NOTICE); } } diff --git a/src/Console/Command/WikiCommand.php b/src/Console/Command/WikiCommand.php index 5fdf979292..67a3f049f0 100644 --- a/src/Console/Command/WikiCommand.php +++ b/src/Console/Command/WikiCommand.php @@ -30,7 +30,6 @@ use FastForward\DevTools\Process\ProcessQueueInterface; use FastForward\DevTools\Path\ManagedWorkspace; use FastForward\DevTools\Project\ProjectCapabilitiesResolverInterface; -use Psr\Log\LoggerInterface; use Psr\Log\LogLevel; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -71,7 +70,6 @@ final class WikiCommand extends Command * @param FilesystemInterface $filesystem the filesystem used to inspect the wiki target * @param GitClientInterface $gitClient * @param ProjectCapabilitiesResolverInterface $projectCapabilitiesResolver the project capability resolver - * @param LoggerInterface $logger the output-aware logger */ public function __construct( private readonly ProcessBuilderInterface $processBuilder, @@ -80,7 +78,6 @@ public function __construct( private readonly FilesystemInterface $filesystem, private readonly GitClientInterface $gitClient, private readonly ProjectCapabilitiesResolverInterface $projectCapabilitiesResolver, - private readonly LoggerInterface $logger, ) { parent::__construct(); } @@ -140,11 +137,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int return $this->initializeWikiSubmodule($input, $target, $processOutput); } - if (! $jsonOutput) { - $this->logger->info('Generating wiki documentation...', [ - 'input' => $input, - ]); - } + $this->log('Generating wiki documentation...', $input); $projectCapabilities = $this->projectCapabilitiesResolver->resolve(wikiTarget: $target); diff --git a/src/Console/DevTools.php b/src/Console/DevTools.php index 42b0912fc5..7360b1a14e 100644 --- a/src/Console/DevTools.php +++ b/src/Console/DevTools.php @@ -27,10 +27,7 @@ use FastForward\DevTools\SelfUpdate\SelfUpdateScopeResolverInterface; use FastForward\DevTools\SelfUpdate\VersionCheckNotifierInterface; use FastForward\DevTools\SelfUpdate\WorkingDirectorySwitcherInterface; -use FastForward\DevTools\ServiceProvider\DevToolsServiceProvider; -use DI\Container; use Override; -use Psr\Container\ContainerInterface; use Symfony\Component\Console\Application; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\CommandLoader\CommandLoaderInterface; @@ -65,11 +62,6 @@ final class DevTools extends Application */ private const array RAW_OUTPUT_COMMANDS = ['changelog:next-version', 'changelog:show']; - /** - * @var ContainerInterface holds the static container instance for global access within the DevTools context - */ - private static ?ContainerInterface $container = null; - /** * Initializes the DevTools global context and dependency graph. * @@ -170,29 +162,6 @@ public function doRun(InputInterface $input, OutputInterface $output): int return parent::doRun($input, $output); } - /** - * Create DevTools instance from container. - * - * @return DevTools - */ - public static function create(): self - { - return self::getContainer()->get(self::class); - } - - /** - * Retrieves the shared DevTools service container. - */ - public static function getContainer(): ContainerInterface - { - if (! self::$container instanceof ContainerInterface) { - $serviceProvider = new DevToolsServiceProvider(); - self::$container = new Container($serviceProvider->getFactories()); - } - - return self::$container; - } - /** * Resolves the raw working-directory option before command parsing. * diff --git a/src/Console/Input/HasJsonOption.php b/src/Console/Input/HasJsonOption.php index 40ef3fafd6..5031ce0a02 100644 --- a/src/Console/Input/HasJsonOption.php +++ b/src/Console/Input/HasJsonOption.php @@ -19,8 +19,11 @@ namespace FastForward\DevTools\Console\Input; +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Environment\RuntimeEnvironmentInterface; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; +use Throwable; /** * Provides the standard JSON output options used by DevTools commands. @@ -56,7 +59,11 @@ protected function isJsonOutput(InputInterface $input): bool return true; } - return (bool) $input->getOption('json'); + if ($this->isOptionEnabled($input, 'json')) { + return true; + } + + return $this->isImplicitJsonOutputEnabled(); } /** @@ -66,6 +73,59 @@ protected function isJsonOutput(InputInterface $input): bool */ protected function isPrettyJsonOutput(InputInterface $input): bool { - return (bool) $input->getOption('pretty-json'); + return $this->isOptionEnabled($input, 'pretty-json'); + } + + /** + * Determines whether structured JSON output SHOULD be enabled implicitly. + * + * Commands MAY opt into runtime-environment-aware behavior by exposing a + * `$runtimeEnvironment` property. Commands that do not expose it SHALL fall + * back to the shared runtime-environment service from the DevTools container. + */ + private function isImplicitJsonOutputEnabled(): bool + { + $runtimeEnvironment = $this->resolveRuntimeEnvironment(); + + if (! $runtimeEnvironment instanceof RuntimeEnvironmentInterface) { + $runtimeEnvironment = ContainerFactory::get(RuntimeEnvironmentInterface::class); + } + + if (! $runtimeEnvironment instanceof RuntimeEnvironmentInterface) { + return false; + } + + return $runtimeEnvironment->isAgentPresent() && ! $runtimeEnvironment->isComposerTestRun(); + } + + /** + * @return ?RuntimeEnvironmentInterface + */ + private function resolveRuntimeEnvironment(): ?RuntimeEnvironmentInterface + { + if (! property_exists($this, 'runtimeEnvironment')) { + return null; + } + + if (! $this->runtimeEnvironment instanceof RuntimeEnvironmentInterface) { + return null; + } + + return $this->runtimeEnvironment; + } + + /** + * Determines whether a boolean input option was enabled. + * + * @param InputInterface $input + * @param string $option + */ + private function isOptionEnabled(InputInterface $input, string $option): bool + { + try { + return (bool) $input->getOption($option); + } catch (Throwable) { + return false; + } } } diff --git a/src/Console/Logger/OutputFormatLogger.php b/src/Console/Logger/OutputFormatLogger.php index f20790c594..de824a0b6d 100644 --- a/src/Console/Logger/OutputFormatLogger.php +++ b/src/Console/Logger/OutputFormatLogger.php @@ -21,7 +21,7 @@ use Stringable; use DateTimeInterface; -use Ergebnis\AgentDetector\Detector; +use FastForward\DevTools\Environment\RuntimeEnvironmentInterface; use FastForward\DevTools\Console\Logger\Processor\ContextProcessorInterface; use FastForward\DevTools\Console\Output\GithubActionOutput; use Psr\Clock\ClockInterface; @@ -58,7 +58,7 @@ * @param ArgvInput $input the CLI input instance used to inspect runtime options * @param ConsoleOutputInterface $output the console output instance used for writing log messages * @param ClockInterface $clock provides timestamps for rendered log entries - * @param Detector $agentDetector detects agent-oriented execution environments + * @param RuntimeEnvironmentInterface $runtimeEnvironment resolves runtime-specific output behavior * @param ContextProcessorInterface $contextProcessor expands command input and output metadata * @param GithubActionOutput $githubActionOutput emits GitHub Actions annotations when supported */ @@ -66,7 +66,7 @@ public function __construct( private ArgvInput $input, private ConsoleOutputInterface $output, private ClockInterface $clock, - private Detector $agentDetector, + private RuntimeEnvironmentInterface $runtimeEnvironment, private ContextProcessorInterface $contextProcessor, private GithubActionOutput $githubActionOutput, ) {} @@ -179,7 +179,7 @@ private function isJsonOutput(): bool return true; } - return $this->agentDetector->isAgentPresent($_SERVER); + return $this->runtimeEnvironment->isAgentPresent() && ! $this->runtimeEnvironment->isComposerTestRun(); } /** diff --git a/src/Console/Logger/Processor/CommandInputProcessor.php b/src/Console/Logger/Processor/CommandInputProcessor.php index d9b6581a38..0159210c53 100644 --- a/src/Console/Logger/Processor/CommandInputProcessor.php +++ b/src/Console/Logger/Processor/CommandInputProcessor.php @@ -25,6 +25,7 @@ use Symfony\Component\Console\Input\InputDefinition; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; +use Throwable; /** * Expands command input instances into structured context entries. @@ -113,7 +114,7 @@ private function extractProvidedArguments(InputInterface $input): array $arguments = []; $arrayParameters = $this->resolveArrayParameters($input); - foreach ($input->getArguments() as $name => $value) { + foreach ($this->resolveArguments($input) as $name => $value) { if (null === $value) { continue; } @@ -146,6 +147,20 @@ private function extractProvidedArguments(InputInterface $input): array return $arguments; } + /** + * @param InputInterface $input + * + * @return array + */ + private function resolveArguments(InputInterface $input): array + { + try { + return $input->getArguments(); + } catch (Throwable) { + return []; + } + } + /** * @param InputInterface $input * diff --git a/src/Console/Logger/Processor/CommandOutputProcessor.php b/src/Console/Logger/Processor/CommandOutputProcessor.php index 42fbda0334..e6a1d20412 100644 --- a/src/Console/Logger/Processor/CommandOutputProcessor.php +++ b/src/Console/Logger/Processor/CommandOutputProcessor.php @@ -19,12 +19,18 @@ namespace FastForward\DevTools\Console\Logger\Processor; +use JsonException; use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\OutputInterface; +use function Safe\json_decode; + /** * Converts buffered command output objects into serializable context entries. + * + * JSON payloads are decoded eagerly so parent command envelopes can expose + * nested structured output without re-encoding it as an escaped string. */ final class CommandOutputProcessor implements ContextProcessorInterface { @@ -63,14 +69,277 @@ public function process(array $context): array /** * @param OutputInterface $output * - * @return ?string + * @return mixed */ - private function extractBufferedOutput(OutputInterface $output): ?string + private function extractBufferedOutput(OutputInterface $output): mixed { if (! $output instanceof BufferedOutput) { return null; } - return $output->fetch(); + $content = $output->fetch(); + + return $this->decodeStructuredOutput($content); + } + + /** + * Decodes a buffered output string when it contains JSON content. + * + * @param string $content the buffered output contents + * + * @return mixed the decoded JSON payload or the original string + */ + private function decodeStructuredOutput(string $content): mixed + { + $trimmedContent = trim($content); + + if ('' === $trimmedContent) { + return $content; + } + + try { + return $this->normalizeStructuredPayload(json_decode($trimmedContent, true)); + } catch (JsonException) { + } + + $decodedDocuments = $this->decodeJsonDocumentStream($trimmedContent); + + if (null !== $decodedDocuments) { + return $decodedDocuments; + } + + $decodedStructuredOutput = $this->decodeStructuredOutputAfterTextPreamble($content); + + if (null !== $decodedStructuredOutput) { + return $decodedStructuredOutput; + } + + return $content; + } + + /** + * Decodes structured output that is preceded by plain-text warnings or banners. + * + * Some tooling emits advisory text before a valid JSON payload even when a + * machine-readable format is requested. When the suffix starting at the + * first valid JSON token is fully decodable, the textual preamble SHALL be + * ignored so parent command envelopes remain parseable. + * + * @param string $content the buffered output contents + * + * @return mixed the decoded JSON payload when a valid structured suffix exists + */ + private function decodeStructuredOutputAfterTextPreamble(string $content): mixed + { + $offset = 0; + + while (null !== ($offset = $this->findNextJsonDocumentOffset($content, $offset))) { + $structuredSuffix = trim(substr($content, $offset)); + + if ('' === $structuredSuffix) { + return null; + } + + try { + return $this->normalizeStructuredPayload(json_decode($structuredSuffix, true)); + } catch (JsonException) { + } + + $decodedDocuments = $this->decodeJsonDocumentStream($structuredSuffix); + + if (null !== $decodedDocuments) { + return $decodedDocuments; + } + + ++$offset; + } + + return null; + } + + /** + * Decodes a stream that contains multiple JSON documents separated by whitespace. + * + * @param string $content the buffered output contents + * + * @return ?list the decoded JSON documents when the stream is valid + */ + private function decodeJsonDocumentStream(string $content): ?array + { + $decodedDocuments = []; + $offset = 0; + $length = \strlen($content); + + while ($offset < $length) { + while ($offset < $length && ctype_space($content[$offset])) { + ++$offset; + } + + if ($offset >= $length) { + break; + } + + $document = $this->consumeJsonDocument($content, $offset); + + if (null === $document) { + return null; + } + + try { + $decodedDocuments[] = $this->normalizeStructuredPayload(json_decode($document, true)); + } catch (JsonException) { + return null; + } + } + + return \count($decodedDocuments) > 1 ? $decodedDocuments : null; + } + + /** + * Consumes a single top-level JSON document from a multi-document stream. + * + * @param string $content the buffered output contents + * @param int $offset the current stream offset, advanced past the document on success + * + * @return ?string the extracted JSON document + */ + private function consumeJsonDocument(string $content, int &$offset): ?string + { + $length = \strlen($content); + $start = $offset; + $openingToken = $content[$offset]; + + if ('{' !== $openingToken && '[' !== $openingToken) { + return null; + } + + $depth = 0; + $inString = false; + $escaping = false; + + for (; $offset < $length; ++$offset) { + $character = $content[$offset]; + + if ($inString) { + if ($escaping) { + $escaping = false; + + continue; + } + + if ('\\' === $character) { + $escaping = true; + + continue; + } + + if ('"' === $character) { + $inString = false; + } + + continue; + } + + if ('"' === $character) { + $inString = true; + + continue; + } + + if ('{' === $character || '[' === $character) { + ++$depth; + + continue; + } + + if ('}' === $character || ']' === $character) { + --$depth; + + if (0 === $depth) { + ++$offset; + + return substr($content, $start, $offset - $start); + } + } + } + + return null; + } + + /** + * Finds the offset of the next possible JSON document opening token. + * + * @param string $content the buffered output contents + * @param int $offset the offset from which scanning SHALL start + * + * @return ?int the offset of the next "{" or "[" token + */ + private function findNextJsonDocumentOffset(string $content, int $offset): ?int + { + $length = \strlen($content); + + for (; $offset < $length; ++$offset) { + if ('{' === $content[$offset] || '[' === $content[$offset]) { + return $offset; + } + } + + return null; + } + + /** + * Normalizes decoded structured payloads produced by wrapped tooling. + * + * @param mixed $payload the decoded payload + * + * @return mixed the normalized payload + */ + private function normalizeStructuredPayload(mixed $payload): mixed + { + if (! \is_array($payload)) { + return $payload; + } + + if (! isset($payload['totals']) || ! \is_array($payload['totals'])) { + return $payload; + } + + $changedFilesTotal = $payload['totals']['changed_files'] ?? null; + + if (! \is_int($changedFilesTotal)) { + return $payload; + } + + if (0 === $changedFilesTotal) { + $payload['changed_files'] = []; + + return $payload; + } + + if (! isset($payload['file_diffs']) || ! \is_array($payload['file_diffs'])) { + return $payload; + } + + $changedFiles = []; + + foreach ($payload['file_diffs'] as $fileDiff) { + if (! \is_array($fileDiff)) { + continue; + } + + if (! isset($fileDiff['file'])) { + continue; + } + + if (! \is_string($fileDiff['file'])) { + continue; + } + + $changedFiles[$fileDiff['file']] = $fileDiff['file']; + } + + $payload['changed_files'] = array_values($changedFiles); + + return $payload; } } diff --git a/src/Container/ContainerFactory.php b/src/Container/ContainerFactory.php new file mode 100644 index 0000000000..eec78a5a7b --- /dev/null +++ b/src/Container/ContainerFactory.php @@ -0,0 +1,99 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Container; + +use DI\Container; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use Psr\Container\ContainerInterface; + +/** + * Builds and caches the shared DevTools dependency injection container. + * + * The factory centralizes container bootstrapping so command traits and other + * internal helpers can resolve services without duplicating bootstrap logic or + * depending on the console application entrypoint. + */ +final class ContainerFactory +{ + private static ?Container $container = null; + + /** + * Creates or returns the shared DevTools container instance. + * + * @return ContainerInterface the shared container instance + */ + public static function create(): ContainerInterface + { + if (! self::$container instanceof Container) { + $serviceProvider = new DevToolsServiceProvider(); + self::$container = new Container($serviceProvider->getFactories()); + } + + return self::$container; + } + + /** + * Resolves a service from the shared DevTools container. + * + * @template T + * + * @param string|class-string $id the service identifier + * + * @return mixed|T the resolved service + */ + public static function get(string $id): mixed + { + return self::create()->get($id); + } + + /** + * Returns whether the shared DevTools container can resolve a service. + * + * @param string $id the service identifier + */ + public static function has(string $id): bool + { + return self::create()->has($id); + } + + /** + * Overrides a shared service entry for the current process. + * + * @internal this method exists so tests can replace container entries with doubles + * + * @param string $id the service identifier + * @param mixed $value the replacement service entry + */ + public static function set(string $id, mixed $value): void + { + self::create(); + self::$container?->set($id, $value); + } + + /** + * Resets the cached shared container instance. + * + * @internal this method exists so tests can isolate container state between test cases + */ + public static function reset(): void + { + self::$container = null; + } +} diff --git a/src/ServiceProvider/DevToolsServiceProvider.php b/src/Container/ServiceProvider/DevToolsServiceProvider.php similarity index 96% rename from src/ServiceProvider/DevToolsServiceProvider.php rename to src/Container/ServiceProvider/DevToolsServiceProvider.php index 7b47081c80..4fc468c1f8 100644 --- a/src/ServiceProvider/DevToolsServiceProvider.php +++ b/src/Container/ServiceProvider/DevToolsServiceProvider.php @@ -17,22 +17,20 @@ * @see https://datatracker.ietf.org/doc/html/rfc2119 */ -namespace FastForward\DevTools\ServiceProvider; +namespace FastForward\DevTools\Container\ServiceProvider; use Composer\Plugin\Capability\CommandProvider; +use FastForward\DevTools\Changelog\Checker\UnreleasedEntryChecker; +use FastForward\DevTools\Changelog\Checker\UnreleasedEntryCheckerInterface; use FastForward\DevTools\Changelog\Manager\ChangelogManager; use FastForward\DevTools\Changelog\Manager\ChangelogManagerInterface; use FastForward\DevTools\Changelog\Parser\ChangelogParser; use FastForward\DevTools\Changelog\Parser\ChangelogParserInterface; +use FastForward\DevTools\Changelog\Renderer\MarkdownRenderer; +use FastForward\DevTools\Changelog\Renderer\MarkdownRendererInterface; use FastForward\DevTools\Composer\Capability\DevToolsCommandProvider; use FastForward\DevTools\Composer\Json\ComposerJson; use FastForward\DevTools\Composer\Json\ComposerJsonInterface; -use FastForward\DevTools\Git\GitClient; -use FastForward\DevTools\Git\GitClientInterface; -use FastForward\DevTools\Changelog\Renderer\MarkdownRenderer; -use FastForward\DevTools\Changelog\Renderer\MarkdownRendererInterface; -use FastForward\DevTools\Changelog\Checker\UnreleasedEntryChecker; -use FastForward\DevTools\Changelog\Checker\UnreleasedEntryCheckerInterface; use FastForward\DevTools\Console\CommandLoader\DevToolsCommandLoader; use FastForward\DevTools\Console\Formatter\LogLevelOutputFormatter; use FastForward\DevTools\Console\Logger\OutputFormatLogger; @@ -47,10 +45,12 @@ use FastForward\DevTools\Environment\EnvironmentInterface; use FastForward\DevTools\Environment\RuntimeEnvironment; use FastForward\DevTools\Environment\RuntimeEnvironmentInterface; -use FastForward\DevTools\Filesystem\FinderFactory; -use FastForward\DevTools\Filesystem\FinderFactoryInterface; use FastForward\DevTools\Filesystem\Filesystem; use FastForward\DevTools\Filesystem\FilesystemInterface; +use FastForward\DevTools\Filesystem\FinderFactory; +use FastForward\DevTools\Filesystem\FinderFactoryInterface; +use FastForward\DevTools\Git\GitClient; +use FastForward\DevTools\Git\GitClientInterface; use FastForward\DevTools\GitAttributes\CandidateProvider; use FastForward\DevTools\GitAttributes\CandidateProviderInterface; use FastForward\DevTools\GitAttributes\ExistenceChecker; @@ -73,6 +73,8 @@ use FastForward\DevTools\License\GeneratorInterface; use FastForward\DevTools\License\Resolver; use FastForward\DevTools\License\ResolverInterface; +use FastForward\DevTools\Path\DevToolsPathResolver; +use FastForward\DevTools\Path\WorkingProjectPathResolver; use FastForward\DevTools\Php\Extension; use FastForward\DevTools\Php\ExtensionInterface; use FastForward\DevTools\PhpUnit\Coverage\CoverageSummaryLoader; @@ -87,6 +89,9 @@ use FastForward\DevTools\Process\XdebugDisablingProcessEnvironmentConfigurator; use FastForward\DevTools\Project\ProjectCapabilitiesResolver; use FastForward\DevTools\Project\ProjectCapabilitiesResolverInterface; +use FastForward\DevTools\Psr\Clock\SystemClock; +use FastForward\DevTools\Resource\DifferInterface; +use FastForward\DevTools\Resource\UnifiedDiffer; use FastForward\DevTools\SelfUpdate\ComposerSelfUpdateRunner; use FastForward\DevTools\SelfUpdate\ComposerSelfUpdateScopeResolver; use FastForward\DevTools\SelfUpdate\ComposerVersionChecker; @@ -97,11 +102,6 @@ use FastForward\DevTools\SelfUpdate\VersionCheckNotifierInterface; use FastForward\DevTools\SelfUpdate\WorkingDirectorySwitcher; use FastForward\DevTools\SelfUpdate\WorkingDirectorySwitcherInterface; -use FastForward\DevTools\Path\DevToolsPathResolver; -use FastForward\DevTools\Path\WorkingProjectPathResolver; -use FastForward\DevTools\Psr\Clock\SystemClock; -use FastForward\DevTools\Resource\DifferInterface; -use FastForward\DevTools\Resource\UnifiedDiffer; use Interop\Container\ServiceProviderInterface; use Psr\Clock\ClockInterface; use Psr\Log\LoggerInterface; @@ -123,12 +123,9 @@ use function DI\get; /** - * DevToolsServiceProvider registers the services provided by this package. - * - * This class implements the ServiceProviderInterface from the PHP-Interop container package, - * allowing it to be used with any compatible dependency injection container. + * Registers the services exposed by the DevTools container. */ -final class DevToolsServiceProvider implements ServiceProviderInterface +class DevToolsServiceProvider implements ServiceProviderInterface { /** * @return array diff --git a/src/Environment/RuntimeEnvironment.php b/src/Environment/RuntimeEnvironment.php index e1d2376fcb..8462751e35 100644 --- a/src/Environment/RuntimeEnvironment.php +++ b/src/Environment/RuntimeEnvironment.php @@ -66,10 +66,14 @@ public function isCi(): bool } /** - * Returns whether the Composer test suite runtime flag is enabled. + * Returns whether the current process runs inside the Composer or PHPUnit test runtime. */ public function isComposerTestRun(): bool { + if (\defined('PHPUNIT_COMPOSER_INSTALL')) { + return true; + } + return $this->isEnabled('COMPOSER_TESTS_ARE_RUNNING'); } diff --git a/src/Environment/RuntimeEnvironmentInterface.php b/src/Environment/RuntimeEnvironmentInterface.php index 61001ec762..1ea315e930 100644 --- a/src/Environment/RuntimeEnvironmentInterface.php +++ b/src/Environment/RuntimeEnvironmentInterface.php @@ -44,7 +44,7 @@ public function isGithubActions(): bool; public function isCi(): bool; /** - * Returns whether the Composer test suite runtime flag is enabled. + * Returns whether the current process runs inside the Composer or PHPUnit test runtime. */ public function isComposerTestRun(): bool; diff --git a/tests/Composer/Capability/DevToolsCommandProviderTest.php b/tests/Composer/Capability/DevToolsCommandProviderTest.php index 3d4e21d7d7..c7bf82a998 100644 --- a/tests/Composer/Capability/DevToolsCommandProviderTest.php +++ b/tests/Composer/Capability/DevToolsCommandProviderTest.php @@ -22,29 +22,40 @@ use FastForward\DevTools\Composer\Capability\DevToolsCommandProvider; use FastForward\DevTools\Composer\Command\ProxyCommand; use FastForward\DevTools\Composer\DevToolsPluginInterface; -use FastForward\DevTools\Console\Command\FixtureWithoutAsCommand; use FastForward\DevTools\Console\DevTools; +use FastForward\DevTools\Console\Command\FixtureWithoutAsCommand; +use FastForward\DevTools\Console\Output\GithubActionOutput; +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Path\DevToolsPathResolver; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; -use Psr\Container\ContainerInterface; -use ReflectionProperty; +#[UsesClass(GithubActionOutput::class)] #[CoversClass(DevToolsCommandProvider::class)] -#[UsesClass(DevTools::class)] +#[UsesClass(ContainerFactory::class)] +#[UsesClass(DevToolsPathResolver::class)] +#[UsesClass(DevToolsServiceProvider::class)] #[UsesClass(ProxyCommand::class)] final class DevToolsCommandProviderTest extends TestCase { use ProphecyTrait; - private ObjectProphecy $container; + private ObjectProphecy $plugin; + /** + * @var ObjectProphecy + */ private ObjectProphecy $devTools; - private ObjectProphecy $plugin; + /** + * @var array + */ + private array $applicationCommands = []; private DevToolsCommandProvider $commandProvider; @@ -53,16 +64,9 @@ final class DevToolsCommandProviderTest extends TestCase */ protected function setUp(): void { - $this->container = $this->prophesize(ContainerInterface::class); - $this->devTools = $this->prophesize(DevTools::class); + ContainerFactory::reset(); $this->plugin = $this->prophesize(DevToolsPluginInterface::class); - - $this->container->get(DevTools::class) - ->willReturn($this->devTools->reveal()) - ->shouldBeCalledOnce(); - - $this->devTools->all() - ->willReturn([])->shouldBeCalledOnce(); + $this->devTools = $this->prophesize(DevTools::class); $this->plugin->isRegisteredCommand(null) ->willReturn(false); @@ -89,8 +93,18 @@ protected function setUp(): void 'plugin' => $this->plugin->reveal(), ]); - $property = new ReflectionProperty(DevTools::class, 'container'); - $property->setValue(null, $this->container->reveal()); + $testCase = $this; + $this->devTools->all() + ->will(static fn(): array => $testCase->applicationCommands); + ContainerFactory::set(DevTools::class, $this->devTools->reveal()); + } + + /** + * @return void + */ + protected function tearDown(): void + { + ContainerFactory::reset(); } /** @@ -117,11 +131,9 @@ public function getCommandsWillReturnComposerProxyCommandsForRegisteredSymfonyCo $symfonyCommand->setHelp(''); $symfonyCommand->setHidden(false); - $this->devTools->all() - ->willReturn([ - 'agents' => $symfonyCommand, - ]) - ->shouldBeCalledOnce(); + $this->applicationCommands = [ + 'agents' => $symfonyCommand, + ]; $commands = array_values($this->commandProvider->getCommands()); $command = $commands[0]; @@ -144,12 +156,10 @@ public function getCommandsWillIgnoreAliasEntriesFromApplicationAllRegistry(): v $symfonyCommand->setHelp(''); $symfonyCommand->setHidden(false); - $this->devTools->all() - ->willReturn([ - 'reports:tests' => $symfonyCommand, - 'tests' => $symfonyCommand, - ]) - ->shouldBeCalledOnce(); + $this->applicationCommands = [ + 'reports:tests' => $symfonyCommand, + 'tests' => $symfonyCommand, + ]; $commands = array_values($this->commandProvider->getCommands()); $proxyCommand = $commands[0]; @@ -172,12 +182,10 @@ public function getCommandsWillPreserveSafeAliasesThroughComposerPlugin(): void $symfonyCommand->setHelp(''); $symfonyCommand->setHidden(false); - $this->devTools->all() - ->willReturn([ - 'dev-tools:standards' => $symfonyCommand, - 'standards' => $symfonyCommand, - ]) - ->shouldBeCalledOnce(); + $this->applicationCommands = [ + 'dev-tools:standards' => $symfonyCommand, + 'standards' => $symfonyCommand, + ]; $proxyCommand = array_values($this->commandProvider->getCommands())[0]; @@ -198,12 +206,10 @@ public function getCommandsWillNotExposeSelfUpdateAliasToComposer(): void $symfonyCommand->setHelp(''); $symfonyCommand->setHidden(false); - $this->devTools->all() - ->willReturn([ - 'dev-tools:self-update' => $symfonyCommand, - 'self-update' => $symfonyCommand, - ]) - ->shouldBeCalledOnce(); + $this->applicationCommands = [ + 'dev-tools:self-update' => $symfonyCommand, + 'self-update' => $symfonyCommand, + ]; $proxyCommand = array_values($this->commandProvider->getCommands())[0]; @@ -224,11 +230,9 @@ public function getCommandsWillNotExposeCommandsOwnedByComposer(): void $symfonyCommand->setHelp(''); $symfonyCommand->setHidden(false); - $this->devTools->all() - ->willReturn([ - 'install' => $symfonyCommand, - ]) - ->shouldBeCalledOnce(); + $this->applicationCommands = [ + 'install' => $symfonyCommand, + ]; self::assertSame([], $this->commandProvider->getCommands()); } diff --git a/tests/Config/ComposerDependencyAnalyserConfigTest.php b/tests/Config/ComposerDependencyAnalyserConfigTest.php index 902f086e9e..e2cbbaa1d0 100644 --- a/tests/Config/ComposerDependencyAnalyserConfigTest.php +++ b/tests/Config/ComposerDependencyAnalyserConfigTest.php @@ -111,6 +111,10 @@ static function (Configuration $configuration) use (&$customizeWasCalled): void $configuration->getIgnoreList() ->shouldIgnoreError(ErrorType::UNUSED_DEPENDENCY, null, 'rector/jack') ); + self::assertTrue( + $configuration->getIgnoreList() + ->shouldIgnoreError(ErrorType::UNUSED_DEPENDENCY, null, 'ergebnis/phpunit-agent-reporter') + ); self::assertTrue( $configuration->getIgnoreList() ->shouldIgnoreError(ErrorType::UNUSED_DEPENDENCY, null, 'vendor/custom-package') @@ -123,6 +127,10 @@ static function (Configuration $configuration) use (&$customizeWasCalled): void #[Test] public function itWillExposeReusablePackagedDependencyDefaults(): void { + self::assertContains( + 'ergebnis/phpunit-agent-reporter', + ComposerDependencyAnalyserConfig::DEFAULT_PACKAGED_UNUSED_DEPENDENCIES, + ); self::assertContains( 'rector/jack', ComposerDependencyAnalyserConfig::DEFAULT_PACKAGED_UNUSED_DEPENDENCIES, diff --git a/tests/Console/Command/AgentsCommandTest.php b/tests/Console/Command/AgentsCommandTest.php index 8c6de41d3a..8fad5da6a9 100644 --- a/tests/Console/Command/AgentsCommandTest.php +++ b/tests/Console/Command/AgentsCommandTest.php @@ -25,6 +25,10 @@ use FastForward\DevTools\Path\DevToolsPathResolver; use FastForward\DevTools\Sync\PackagedDirectorySynchronizer; use FastForward\DevTools\Sync\SynchronizeResult; +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Environment\Environment as DevToolsEnvironment; +use FastForward\DevTools\Environment\RuntimeEnvironment; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -34,12 +38,17 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; +use FastForward\DevTools\Tests\Container\UsesContainerFactory; use ReflectionMethod; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use function Safe\getcwd; +#[UsesClass(ContainerFactory::class)] +#[UsesClass(DevToolsServiceProvider::class)] +#[UsesClass(DevToolsEnvironment::class)] +#[UsesClass(RuntimeEnvironment::class)] #[CoversClass(AgentsCommand::class)] #[UsesClass(DevToolsPathResolver::class)] #[UsesClass(PackagedDirectorySynchronizer::class)] @@ -48,6 +57,7 @@ final class AgentsCommandTest extends TestCase { use ProphecyTrait; + use UsesContainerFactory; private ObjectProphecy $synchronizer; @@ -69,16 +79,18 @@ protected function setUp(): void $this->synchronizer = $this->prophesize(PackagedDirectorySynchronizer::class); $this->filesystem = $this->prophesize(FilesystemInterface::class); $this->logger = $this->prophesize(LoggerInterface::class); + $this->setContainerEntry(LoggerInterface::class, $this->logger->reveal()); $this->input = $this->prophesize(InputInterface::class); + + $this->input->getOption('json') + ->willReturn(false); + $this->input->getOption('pretty-json') + ->willReturn(false); $this->output = $this->prophesize(OutputInterface::class); $this->filesystem->getAbsolutePath('.agents/agents') ->willReturn(getcwd() . '/.agents/agents'); - $this->command = new AgentsCommand( - $this->synchronizer->reveal(), - $this->filesystem->reveal(), - $this->logger->reveal(), - ); + $this->command = new AgentsCommand($this->synchronizer->reveal(), $this->filesystem->reveal()); } /** @@ -92,7 +104,9 @@ public function executeWillFailWhenPackagedAgentsDirectoryDoesNotExist(): void $this->filesystem->exists($agentsPath) ->willReturn(false); $this->synchronizer->synchronize(Argument::cetera())->shouldNotBeCalled(); - $this->logger->info('Starting agents synchronization...') + $this->logger->log('info', 'Starting agents synchronization...', [ + 'input' => $this->input->reveal(), + ]) ->shouldBeCalledOnce(); $this->logger->error( 'No packaged .agents/agents found at: {packaged_agents_path}', @@ -125,9 +139,13 @@ public function executeWillCreateAgentsDirectoryWhenItDoesNotExist(): void $this->synchronizer->synchronize($agentsPath, $agentsPath, '.agents/agents') ->willReturn($result) ->shouldBeCalledOnce(); - $this->logger->info('Starting agents synchronization...') + $this->logger->log('info', 'Starting agents synchronization...', [ + 'input' => $this->input->reveal(), + ]) ->shouldBeCalledOnce(); - $this->logger->info('Created .agents/agents directory.') + $this->logger->log('info', 'Created .agents/agents directory.', [ + 'input' => $this->input->reveal(), + ]) ->shouldBeCalledOnce(); $this->logger->log( 'info', @@ -158,7 +176,9 @@ public function executeWillReturnFailureWhenSynchronizerFails(): void $this->synchronizer->synchronize($agentsPath, $agentsPath, '.agents/agents') ->willReturn($result) ->shouldBeCalledOnce(); - $this->logger->info('Starting agents synchronization...') + $this->logger->log('info', 'Starting agents synchronization...', [ + 'input' => $this->input->reveal(), + ]) ->shouldBeCalledOnce(); $this->logger->error( 'Agents synchronization failed.', diff --git a/tests/Console/Command/ChangelogCheckCommandTest.php b/tests/Console/Command/ChangelogCheckCommandTest.php index 962fdc97c5..8cc0d5bdb7 100644 --- a/tests/Console/Command/ChangelogCheckCommandTest.php +++ b/tests/Console/Command/ChangelogCheckCommandTest.php @@ -24,8 +24,15 @@ use FastForward\DevTools\Console\Command\Traits\LogsCommandResults; use FastForward\DevTools\Filesystem\FilesystemInterface; use Psr\Log\LoggerInterface; +use FastForward\DevTools\Tests\Container\UsesContainerFactory; +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Environment\Environment as DevToolsEnvironment; +use FastForward\DevTools\Environment\RuntimeEnvironment; +use FastForward\DevTools\Path\DevToolsPathResolver; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\Attributes\UsesTrait; use PHPUnit\Framework\TestCase; use Prophecy\Argument; @@ -35,11 +42,17 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +#[UsesClass(ContainerFactory::class)] +#[UsesClass(DevToolsPathResolver::class)] +#[UsesClass(DevToolsServiceProvider::class)] +#[UsesClass(DevToolsEnvironment::class)] +#[UsesClass(RuntimeEnvironment::class)] #[CoversClass(ChangelogCheckCommand::class)] #[UsesTrait(LogsCommandResults::class)] final class ChangelogCheckCommandTest extends TestCase { use ProphecyTrait; + use UsesContainerFactory; /** * @var ObjectProphecy @@ -67,7 +80,13 @@ protected function setUp(): void $this->checker = $this->prophesize(UnreleasedEntryCheckerInterface::class); $this->filesystem = $this->prophesize(FilesystemInterface::class); $this->logger = $this->prophesize(LoggerInterface::class); + $this->setContainerEntry(LoggerInterface::class, $this->logger->reveal()); $this->input = $this->prophesize(InputInterface::class); + + $this->input->getOption('json') + ->willReturn(false); + $this->input->getOption('pretty-json') + ->willReturn(false); $this->output = $this->prophesize(OutputInterface::class); $this->input->getOption('against') ->willReturn(null); @@ -75,11 +94,7 @@ protected function setUp(): void ->willReturn('CHANGELOG.md'); $this->filesystem->getAbsolutePath('CHANGELOG.md') ->willReturn('/repo/CHANGELOG.md'); - $this->command = new ChangelogCheckCommand( - $this->filesystem->reveal(), - $this->checker->reveal(), - $this->logger->reveal(), - ); + $this->command = new ChangelogCheckCommand($this->filesystem->reveal(), $this->checker->reveal()); } /** diff --git a/tests/Console/Command/ChangelogEntryCommandTest.php b/tests/Console/Command/ChangelogEntryCommandTest.php index 9d5b551752..c3f175dea4 100644 --- a/tests/Console/Command/ChangelogEntryCommandTest.php +++ b/tests/Console/Command/ChangelogEntryCommandTest.php @@ -23,7 +23,13 @@ use FastForward\DevTools\Changelog\Manager\ChangelogManagerInterface; use FastForward\DevTools\Console\Command\ChangelogEntryCommand; use FastForward\DevTools\Console\Command\Traits\LogsCommandResults; +use FastForward\DevTools\Console\Output\GithubActionOutput; use FastForward\DevTools\Filesystem\FilesystemInterface; +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Environment\Environment as DevToolsEnvironment; +use FastForward\DevTools\Environment\RuntimeEnvironment; +use FastForward\DevTools\Path\DevToolsPathResolver; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -32,16 +38,24 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; +use FastForward\DevTools\Tests\Container\UsesContainerFactory; use ReflectionMethod; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +#[UsesClass(ContainerFactory::class)] +#[UsesClass(DevToolsPathResolver::class)] +#[UsesClass(DevToolsServiceProvider::class)] +#[UsesClass(DevToolsEnvironment::class)] +#[UsesClass(RuntimeEnvironment::class)] +#[UsesClass(GithubActionOutput::class)] #[CoversClass(ChangelogEntryCommand::class)] #[UsesClass(ChangelogEntryType::class)] #[UsesTrait(LogsCommandResults::class)] final class ChangelogEntryCommandTest extends TestCase { use ProphecyTrait; + use UsesContainerFactory; private ObjectProphecy $filesystem; @@ -63,7 +77,13 @@ protected function setUp(): void $this->filesystem = $this->prophesize(FilesystemInterface::class); $this->changelogManager = $this->prophesize(ChangelogManagerInterface::class); $this->logger = $this->prophesize(LoggerInterface::class); + $this->setContainerEntry(LoggerInterface::class, $this->logger->reveal()); $this->input = $this->prophesize(InputInterface::class); + + $this->input->getOption('json') + ->willReturn(false); + $this->input->getOption('pretty-json') + ->willReturn(false); $this->output = $this->prophesize(OutputInterface::class); $this->input->getOption('file') @@ -82,7 +102,6 @@ protected function setUp(): void $this->command = new ChangelogEntryCommand( $this->filesystem->reveal(), $this->changelogManager->reveal(), - $this->logger->reveal(), ); } diff --git a/tests/Console/Command/ChangelogNextVersionCommandTest.php b/tests/Console/Command/ChangelogNextVersionCommandTest.php index 6cb33a4fee..a1897e4cf4 100644 --- a/tests/Console/Command/ChangelogNextVersionCommandTest.php +++ b/tests/Console/Command/ChangelogNextVersionCommandTest.php @@ -23,24 +23,37 @@ use FastForward\DevTools\Console\Command\ChangelogNextVersionCommand; use FastForward\DevTools\Console\Command\Traits\LogsCommandResults; use FastForward\DevTools\Filesystem\FilesystemInterface; +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Environment\Environment as DevToolsEnvironment; +use FastForward\DevTools\Environment\RuntimeEnvironment; +use FastForward\DevTools\Path\DevToolsPathResolver; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\Attributes\UsesTrait; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; +use FastForward\DevTools\Tests\Container\UsesContainerFactory; use ReflectionMethod; use RuntimeException; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +#[UsesClass(ContainerFactory::class)] +#[UsesClass(DevToolsPathResolver::class)] +#[UsesClass(DevToolsServiceProvider::class)] +#[UsesClass(DevToolsEnvironment::class)] +#[UsesClass(RuntimeEnvironment::class)] #[CoversClass(ChangelogNextVersionCommand::class)] #[UsesTrait(LogsCommandResults::class)] final class ChangelogNextVersionCommandTest extends TestCase { use ProphecyTrait; + use UsesContainerFactory; /** * @var ObjectProphecy @@ -71,6 +84,7 @@ protected function setUp(): void $this->filesystem = $this->prophesize(FilesystemInterface::class); $this->changelogManager = $this->prophesize(ChangelogManagerInterface::class); $this->logger = $this->prophesize(LoggerInterface::class); + $this->setContainerEntry(LoggerInterface::class, $this->logger->reveal()); $this->input = $this->prophesize(InputInterface::class); $this->output = $this->prophesize(OutputInterface::class); @@ -88,7 +102,6 @@ protected function setUp(): void $this->command = new ChangelogNextVersionCommand( $this->filesystem->reveal(), $this->changelogManager->reveal(), - $this->logger->reveal(), ); } diff --git a/tests/Console/Command/ChangelogPromoteCommandTest.php b/tests/Console/Command/ChangelogPromoteCommandTest.php index cbd575a700..dcbd11f101 100644 --- a/tests/Console/Command/ChangelogPromoteCommandTest.php +++ b/tests/Console/Command/ChangelogPromoteCommandTest.php @@ -24,24 +24,37 @@ use FastForward\DevTools\Console\Command\ChangelogPromoteCommand; use FastForward\DevTools\Console\Command\Traits\LogsCommandResults; use FastForward\DevTools\Filesystem\FilesystemInterface; +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Environment\Environment as DevToolsEnvironment; +use FastForward\DevTools\Environment\RuntimeEnvironment; +use FastForward\DevTools\Path\DevToolsPathResolver; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\Attributes\UsesTrait; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Clock\ClockInterface; use Psr\Log\LoggerInterface; +use FastForward\DevTools\Tests\Container\UsesContainerFactory; use ReflectionMethod; use RuntimeException; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +#[UsesClass(ContainerFactory::class)] +#[UsesClass(DevToolsPathResolver::class)] +#[UsesClass(DevToolsServiceProvider::class)] +#[UsesClass(DevToolsEnvironment::class)] +#[UsesClass(RuntimeEnvironment::class)] #[CoversClass(ChangelogPromoteCommand::class)] #[UsesTrait(LogsCommandResults::class)] final class ChangelogPromoteCommandTest extends TestCase { use ProphecyTrait; + use UsesContainerFactory; private ObjectProphecy $filesystem; @@ -66,7 +79,13 @@ protected function setUp(): void $this->changelogManager = $this->prophesize(ChangelogManagerInterface::class); $this->clock = $this->prophesize(ClockInterface::class); $this->logger = $this->prophesize(LoggerInterface::class); + $this->setContainerEntry(LoggerInterface::class, $this->logger->reveal()); $this->input = $this->prophesize(InputInterface::class); + + $this->input->getOption('json') + ->willReturn(false); + $this->input->getOption('pretty-json') + ->willReturn(false); $this->output = $this->prophesize(OutputInterface::class); $this->input->getOption('file') @@ -84,7 +103,6 @@ protected function setUp(): void $this->filesystem->reveal(), $this->changelogManager->reveal(), $this->clock->reveal(), - $this->logger->reveal(), ); } diff --git a/tests/Console/Command/ChangelogShowCommandTest.php b/tests/Console/Command/ChangelogShowCommandTest.php index 224c33942f..2d7033f7d2 100644 --- a/tests/Console/Command/ChangelogShowCommandTest.php +++ b/tests/Console/Command/ChangelogShowCommandTest.php @@ -23,24 +23,37 @@ use FastForward\DevTools\Console\Command\ChangelogShowCommand; use FastForward\DevTools\Console\Command\Traits\LogsCommandResults; use FastForward\DevTools\Filesystem\FilesystemInterface; +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Environment\Environment as DevToolsEnvironment; +use FastForward\DevTools\Environment\RuntimeEnvironment; +use FastForward\DevTools\Path\DevToolsPathResolver; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\Attributes\UsesTrait; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; +use FastForward\DevTools\Tests\Container\UsesContainerFactory; use ReflectionMethod; use RuntimeException; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +#[UsesClass(ContainerFactory::class)] +#[UsesClass(DevToolsPathResolver::class)] +#[UsesClass(DevToolsServiceProvider::class)] +#[UsesClass(DevToolsEnvironment::class)] +#[UsesClass(RuntimeEnvironment::class)] #[CoversClass(ChangelogShowCommand::class)] #[UsesTrait(LogsCommandResults::class)] final class ChangelogShowCommandTest extends TestCase { use ProphecyTrait; + use UsesContainerFactory; private ObjectProphecy $changelogManager; @@ -62,6 +75,7 @@ protected function setUp(): void $this->changelogManager = $this->prophesize(ChangelogManagerInterface::class); $this->filesystem = $this->prophesize(FilesystemInterface::class); $this->logger = $this->prophesize(LoggerInterface::class); + $this->setContainerEntry(LoggerInterface::class, $this->logger->reveal()); $this->input = $this->prophesize(InputInterface::class); $this->output = $this->prophesize(OutputInterface::class); @@ -76,11 +90,7 @@ protected function setUp(): void $this->filesystem->getAbsolutePath('CHANGELOG.md') ->willReturn('/repo/CHANGELOG.md'); - $this->command = new ChangelogShowCommand( - $this->filesystem->reveal(), - $this->changelogManager->reveal(), - $this->logger->reveal(), - ); + $this->command = new ChangelogShowCommand($this->filesystem->reveal(), $this->changelogManager->reveal()); } /** diff --git a/tests/Console/Command/CodeOwnersCommandTest.php b/tests/Console/Command/CodeOwnersCommandTest.php index d2e1b7b089..398df7f8e5 100644 --- a/tests/Console/Command/CodeOwnersCommandTest.php +++ b/tests/Console/Command/CodeOwnersCommandTest.php @@ -27,6 +27,11 @@ use FastForward\DevTools\Filesystem\FilesystemInterface; use FastForward\DevTools\Resource\FileDiff; use FastForward\DevTools\Resource\FileDiffer; +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Environment\Environment as DevToolsEnvironment; +use FastForward\DevTools\Environment\RuntimeEnvironment; +use FastForward\DevTools\Path\DevToolsPathResolver; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -36,16 +41,23 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; +use FastForward\DevTools\Tests\Container\UsesContainerFactory; use ReflectionMethod; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +#[UsesClass(ContainerFactory::class)] +#[UsesClass(DevToolsPathResolver::class)] +#[UsesClass(DevToolsServiceProvider::class)] +#[UsesClass(DevToolsEnvironment::class)] +#[UsesClass(RuntimeEnvironment::class)] #[CoversClass(CodeOwnersCommand::class)] #[UsesClass(FileDiff::class)] #[UsesTrait(LogsCommandResults::class)] final class CodeOwnersCommandTest extends TestCase { use ProphecyTrait; + use UsesContainerFactory; /** * @var ObjectProphecy @@ -91,9 +103,15 @@ protected function setUp(): void $this->generator = $this->prophesize(CodeOwnersGenerator::class); $this->filesystem = $this->prophesize(FilesystemInterface::class); $this->input = $this->prophesize(InputInterface::class); + + $this->input->getOption('json') + ->willReturn(false); + $this->input->getOption('pretty-json') + ->willReturn(false); $this->output = $this->prophesize(OutputInterface::class); $this->fileDiffer = $this->prophesize(FileDiffer::class); $this->logger = $this->prophesize(LoggerInterface::class); + $this->setContainerEntry(LoggerInterface::class, $this->logger->reveal()); $this->io = $this->prophesize(SymfonyStyle::class); $this->input->getOption('file') @@ -113,15 +131,14 @@ protected function setUp(): void ->willReturn(false); $this->output->writeln(Argument::any()); $this->fileDiffer->formatForConsole(Argument::cetera())->willReturn(null); - $this->logger->info(Argument::cetera())->will(static function (): void {}); - $this->logger->notice(Argument::cetera())->will(static function (): void {}); + $this->logger->log('info', Argument::cetera())->will(static function (): void {}); + $this->logger->log('notice', Argument::cetera())->will(static function (): void {}); $this->logger->log(Argument::cetera())->will(static function (): void {}); $this->logger->error(Argument::cetera())->will(static function (): void {}); $this->command = new CodeOwnersCommand( $this->generator->reveal(), $this->filesystem->reveal(), $this->fileDiffer->reveal(), - $this->logger->reveal(), $this->io->reveal(), ); } diff --git a/tests/Console/Command/CodeStyleCommandTest.php b/tests/Console/Command/CodeStyleCommandTest.php index 161365bdba..5be14906c3 100644 --- a/tests/Console/Command/CodeStyleCommandTest.php +++ b/tests/Console/Command/CodeStyleCommandTest.php @@ -25,6 +25,10 @@ use FastForward\DevTools\Path\WorkingProjectPathResolver; use FastForward\DevTools\Process\ProcessBuilderInterface; use FastForward\DevTools\Process\ProcessQueueInterface; +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Environment\Environment as DevToolsEnvironment; +use FastForward\DevTools\Environment\RuntimeEnvironment; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -34,6 +38,7 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; +use FastForward\DevTools\Tests\Container\UsesContainerFactory; use ReflectionMethod; use Symfony\Component\Config\FileLocatorInterface; use Symfony\Component\Console\Formatter\OutputFormatter; @@ -41,6 +46,10 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Process\Process; +#[UsesClass(ContainerFactory::class)] +#[UsesClass(DevToolsServiceProvider::class)] +#[UsesClass(DevToolsEnvironment::class)] +#[UsesClass(RuntimeEnvironment::class)] #[CoversClass(CodeStyleCommand::class)] #[UsesClass(DevToolsPathResolver::class)] #[UsesClass(WorkingProjectPathResolver::class)] @@ -48,6 +57,7 @@ final class CodeStyleCommandTest extends TestCase { use ProphecyTrait; + use UsesContainerFactory; private ObjectProphecy $fileLocator; @@ -77,6 +87,7 @@ protected function setUp(): void $this->input = $this->prophesize(InputInterface::class); $this->output = $this->prophesize(OutputInterface::class); $this->logger = $this->prophesize(LoggerInterface::class); + $this->setContainerEntry(LoggerInterface::class, $this->logger->reveal()); $this->input->getOption('fix') ->willReturn(false); @@ -106,7 +117,6 @@ protected function setUp(): void $this->fileLocator->reveal(), $this->processBuilder->reveal(), $this->processQueue->reveal(), - $this->logger->reveal(), ); } @@ -122,17 +132,17 @@ public function executeWillReturnSuccessWhenProcessQueueSucceeds(): void $this->processQueue->run(Argument::type('object')) ->willReturn(CodeStyleCommand::SUCCESS) ->shouldBeCalled(); - $this->logger->info('Running code style checks and fixes...') + $this->logger->log('info', 'Running code style checks and fixes...', Argument::that( + fn(array $context): bool => $this->input->reveal() === $context['input'] + )) ->shouldBeCalled(); $this->logger->log( 'info', 'Code style checks completed successfully.', - [ - 'input' => $this->input->reveal(), - 'fix' => false, - 'config' => CodeStyleCommand::CONFIG, - 'process_output' => null, - ], + Argument::that(fn(array $context): bool => $this->input->reveal() === $context['input'] + && false === $context['fix'] + && CodeStyleCommand::CONFIG === $context['config'] + && $context['output'] instanceof OutputInterface), )->shouldBeCalled(); self::assertSame(CodeStyleCommand::SUCCESS, $this->executeCommand()); @@ -147,18 +157,18 @@ public function executeWillReturnFailureWhenProcessQueueFails(): void $this->processQueue->run(Argument::type('object')) ->willReturn(CodeStyleCommand::FAILURE) ->shouldBeCalled(); - $this->logger->info('Running code style checks and fixes...') + $this->logger->log('info', 'Running code style checks and fixes...', Argument::that( + fn(array $context): bool => $this->input->reveal() === $context['input'] + )) ->shouldBeCalled(); $this->logger->error( 'Code style checks failed.', - [ - 'input' => $this->input->reveal(), - 'file' => null, - 'line' => null, - 'fix' => false, - 'config' => CodeStyleCommand::CONFIG, - 'process_output' => null, - ], + Argument::that(fn(array $context): bool => $this->input->reveal() === $context['input'] + && null === $context['file'] + && null === $context['line'] + && false === $context['fix'] + && CodeStyleCommand::CONFIG === $context['config'] + && $context['output'] instanceof OutputInterface), )->shouldBeCalled(); self::assertSame(CodeStyleCommand::FAILURE, $this->executeCommand()); @@ -180,13 +190,11 @@ public function executeWillCaptureBufferedOutputWhenJsonIsRequested(): void $this->processQueue->run(Argument::type('object')) ->willReturn(CodeStyleCommand::SUCCESS) ->shouldBeCalled(); - $this->logger->info('Running code style checks and fixes...') - ->shouldBeCalled(); $this->logger->log( 'info', 'Code style checks completed successfully.', Argument::that(fn(array $context): bool => $this->input->reveal() === $context['input'] - && \is_string($context['process_output'])), + && $context['output'] instanceof OutputInterface), )->shouldBeCalled(); self::assertSame(CodeStyleCommand::SUCCESS, $this->executeCommand()); diff --git a/tests/Console/Command/CopyResourceCommandTest.php b/tests/Console/Command/CopyResourceCommandTest.php index df7a3fda11..bfea9ea026 100644 --- a/tests/Console/Command/CopyResourceCommandTest.php +++ b/tests/Console/Command/CopyResourceCommandTest.php @@ -26,6 +26,11 @@ use FastForward\DevTools\Filesystem\FilesystemInterface; use FastForward\DevTools\Resource\FileDiff; use FastForward\DevTools\Resource\FileDiffer; +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Environment\Environment as DevToolsEnvironment; +use FastForward\DevTools\Environment\RuntimeEnvironment; +use FastForward\DevTools\Path\DevToolsPathResolver; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -34,6 +39,7 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; +use FastForward\DevTools\Tests\Container\UsesContainerFactory; use ReflectionMethod; use Symfony\Component\Config\FileLocatorInterface; use Symfony\Component\Console\Input\InputInterface; @@ -45,11 +51,17 @@ use function Safe\unlink; use function Safe\rmdir; +#[UsesClass(ContainerFactory::class)] +#[UsesClass(DevToolsPathResolver::class)] +#[UsesClass(DevToolsServiceProvider::class)] +#[UsesClass(DevToolsEnvironment::class)] +#[UsesClass(RuntimeEnvironment::class)] #[CoversClass(CopyResourceCommand::class)] #[UsesClass(FileDiff::class)] final class CopyResourceCommandTest extends TestCase { use ProphecyTrait; + use UsesContainerFactory; private ObjectProphecy $filesystem; @@ -84,9 +96,15 @@ protected function setUp(): void $this->fileLocator = $this->prophesize(FileLocatorInterface::class); $this->finderFactory = $this->prophesize(FinderFactoryInterface::class); $this->input = $this->prophesize(InputInterface::class); + + $this->input->getOption('json') + ->willReturn(false); + $this->input->getOption('pretty-json') + ->willReturn(false); $this->output = $this->prophesize(OutputInterface::class); $this->fileDiffer = $this->prophesize(FileDiffer::class); $this->logger = $this->prophesize(LoggerInterface::class); + $this->setContainerEntry(LoggerInterface::class, $this->logger->reveal()); $this->io = $this->prophesize(SymfonyStyle::class); $this->output->isDecorated() ->willReturn(false); @@ -101,15 +119,14 @@ protected function setUp(): void ->willReturn(false); $this->fileDiffer->formatForConsole(Argument::cetera()) ->will(static fn(array $arguments): ?string => $arguments[0]); - $this->logger->info(Argument::cetera())->will(static function (): void {}); - $this->logger->notice(Argument::cetera())->will(static function (): void {}); + $this->logger->log('info', Argument::cetera())->will(static function (): void {}); + $this->logger->log('notice', Argument::cetera())->will(static function (): void {}); $this->logger->error(Argument::cetera())->will(static function (): void {}); $this->command = new CopyResourceCommand( $this->filesystem->reveal(), $this->fileLocator->reveal(), $this->finderFactory->reveal(), $this->fileDiffer->reveal(), - $this->logger->reveal(), $this->io->reveal(), ); } @@ -256,11 +273,13 @@ public function executeWillShowDiffBeforeOverwritingChangedFile(): void )) ->shouldBeCalledOnce(); - $this->logger->notice( + $this->logger->log( + 'notice', 'Overwriting resource /project/.editorconfig from /package/.editorconfig.', Argument::type('array'), )->shouldBeCalledOnce(); - $this->logger->notice( + $this->logger->log( + 'notice', "--- Current: /project/.editorconfig\n+++ Source: /package/.editorconfig\n@@ -1 +1 @@\n-old\n+new", Argument::type('array'), )->shouldBeCalledOnce(); @@ -299,7 +318,8 @@ public function executeWillReportUnchangedOverwriteAsNoOp(): void )) ->shouldBeCalledOnce(); - $this->logger->notice( + $this->logger->log( + 'notice', 'Target /project/.editorconfig already matches source /package/.editorconfig; overwrite skipped.', Argument::type('array'), )->shouldBeCalledOnce(); @@ -338,7 +358,8 @@ public function executeWillHandleBinaryOverwriteGracefully(): void )) ->shouldBeCalledOnce(); - $this->logger->notice( + $this->logger->log( + 'notice', 'Target /project/.editorconfig will be overwritten from /package/.editorconfig, but a text diff is unavailable for binary content.', Argument::type('array'), )->shouldBeCalledOnce(); @@ -374,9 +395,9 @@ public function executeWillReturnFailureInCheckModeWhenFileWouldChange(): void $this->fileDiffer->diff('/package/.editorconfig', '/project/.editorconfig') ->willReturn(new FileDiff(FileDiff::STATUS_CHANGED, 'Changed summary', "@@ -1 +1 @@\n-old\n+new")) ->shouldBeCalledOnce(); - $this->logger->notice('Changed summary', Argument::type('array')) + $this->logger->log('notice', 'Changed summary', Argument::type('array')) ->shouldBeCalledOnce(); - $this->logger->notice("@@ -1 +1 @@\n-old\n+new", Argument::type('array')) + $this->logger->log('notice', "@@ -1 +1 @@\n-old\n+new", Argument::type('array')) ->shouldBeCalledOnce(); $this->filesystem->copy(Argument::cetera())->shouldNotBeCalled(); diff --git a/tests/Console/Command/DependenciesCommandTest.php b/tests/Console/Command/DependenciesCommandTest.php index f8701a2d96..275e672b0a 100644 --- a/tests/Console/Command/DependenciesCommandTest.php +++ b/tests/Console/Command/DependenciesCommandTest.php @@ -27,6 +27,10 @@ use FastForward\DevTools\Process\ProcessBuilder; use FastForward\DevTools\Process\ProcessBuilderInterface; use FastForward\DevTools\Process\ProcessQueueInterface; +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Environment\Environment as DevToolsEnvironment; +use FastForward\DevTools\Environment\RuntimeEnvironment; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -36,6 +40,7 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; +use FastForward\DevTools\Tests\Container\UsesContainerFactory; use ReflectionMethod; use Symfony\Component\Config\FileLocatorInterface; use Symfony\Component\Console\Formatter\OutputFormatter; @@ -43,6 +48,10 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Process\Process; +#[UsesClass(ContainerFactory::class)] +#[UsesClass(DevToolsServiceProvider::class)] +#[UsesClass(DevToolsEnvironment::class)] +#[UsesClass(RuntimeEnvironment::class)] #[CoversClass(DependenciesCommand::class)] #[UsesClass(DevToolsPathResolver::class)] #[UsesClass(WorkingProjectPathResolver::class)] @@ -51,6 +60,7 @@ final class DependenciesCommandTest extends TestCase { use ProphecyTrait; + use UsesContainerFactory; private ObjectProphecy $processQueue; @@ -74,6 +84,7 @@ protected function setUp(): void $this->output = $this->prophesize(OutputInterface::class); $this->fileLocator = $this->prophesize(FileLocatorInterface::class); $this->logger = $this->prophesize(LoggerInterface::class); + $this->setContainerEntry(LoggerInterface::class, $this->logger->reveal()); $this->fileLocator->locate('composer-dependency-analyser.php') ->willReturn('/app/composer-dependency-analyser.php'); @@ -102,7 +113,6 @@ protected function setUp(): void new ProcessBuilder(), $this->processQueue->reveal(), $this->fileLocator->reveal(), - $this->logger->reveal(), ); } @@ -116,7 +126,7 @@ public function executeWillReturnSuccessWhenPreviewAndAnalyzersSucceed(): void $this->processQueue->run(Argument::type('object')) ->willReturn(DependenciesCommand::SUCCESS) ->shouldBeCalledOnce(); - $this->logger->info('Running dependency analysis...', Argument::that( + $this->logger->log('info', 'Running dependency analysis...', Argument::that( static fn(array $context): bool => $context['input'] instanceof InputInterface )) ->shouldBeCalledOnce(); @@ -160,7 +170,7 @@ public function executeWillIgnoreJackFailuresWhenMaxOutdatedIsDisabled(): void $this->processQueue->run(Argument::type('object')) ->willReturn(DependenciesCommand::SUCCESS) ->shouldBeCalledOnce(); - $this->logger->info('Running dependency analysis...', Argument::that( + $this->logger->log('info', 'Running dependency analysis...', Argument::that( static fn(array $context): bool => $context['input'] instanceof InputInterface )) ->shouldBeCalledOnce(); @@ -174,6 +184,30 @@ public function executeWillIgnoreJackFailuresWhenMaxOutdatedIsDisabled(): void self::assertSame(DependenciesCommand::SUCCESS, $this->executeCommand()); } + /** + * @return void + */ + #[Test] + public function executeWillSuppressProgressLogWhenJsonIsRequested(): void + { + $this->input->getOption('json') + ->willReturn(true); + $this->input->getOption('pretty-json') + ->willReturn(false); + $this->processQueue->add(Argument::type(Process::class), false, Argument::cetera())->shouldBeCalledTimes(4); + $this->processQueue->run(Argument::type(OutputInterface::class)) + ->willReturn(DependenciesCommand::SUCCESS) + ->shouldBeCalledOnce(); + $this->logger->log( + 'info', + 'Dependency analysis completed successfully.', + Argument::that(static fn(array $context): bool => $context['input'] instanceof InputInterface + && $context['output'] instanceof OutputInterface), + )->shouldBeCalledOnce(); + + self::assertSame(DependenciesCommand::SUCCESS, $this->executeCommand()); + } + /** * @return void */ @@ -221,7 +255,6 @@ private function assertComposerDependencyAnalyserEnvironment(string $expectedVal $processBuilder->reveal(), $this->processQueue->reveal(), $this->fileLocator->reveal(), - $this->logger->reveal(), ); $processBuilder->withArgument('--config', '/app/composer-dependency-analyser.php') @@ -253,7 +286,6 @@ public function jackBreakpointProcessWillUseTheResolvedJackBinary(): void $processBuilder->reveal(), $this->processQueue->reveal(), $this->fileLocator->reveal(), - $this->logger->reveal(), ); $processBuilder->withArgument('--limit', '5') @@ -279,7 +311,6 @@ public function openVersionsProcessWillUseTheResolvedJackBinary(): void $processBuilder->reveal(), $this->processQueue->reveal(), $this->fileLocator->reveal(), - $this->logger->reveal(), ); $processBuilder->withArgument('--dry-run') @@ -305,7 +336,6 @@ public function raiseToInstalledProcessWillUseTheResolvedJackBinary(): void $processBuilder->reveal(), $this->processQueue->reveal(), $this->fileLocator->reveal(), - $this->logger->reveal(), ); $processBuilder->withArgument('--dry-run') diff --git a/tests/Console/Command/DocsCommandTest.php b/tests/Console/Command/DocsCommandTest.php index 4684f6e031..9d9aa56a12 100644 --- a/tests/Console/Command/DocsCommandTest.php +++ b/tests/Console/Command/DocsCommandTest.php @@ -30,6 +30,10 @@ use FastForward\DevTools\Project\ProjectCapabilities; use FastForward\DevTools\Project\ProjectCapabilitiesResolverInterface; use FastForward\DevTools\Path\WorkingProjectPathResolver; +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Environment\Environment as DevToolsEnvironment; +use FastForward\DevTools\Environment\RuntimeEnvironment; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\TestWith; @@ -40,6 +44,7 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; +use FastForward\DevTools\Tests\Container\UsesContainerFactory; use ReflectionMethod; use Symfony\Component\Console\Formatter\OutputFormatter; use Symfony\Component\Console\Input\InputInterface; @@ -47,6 +52,10 @@ use Symfony\Component\Process\Process; use Twig\Environment; +#[UsesClass(ContainerFactory::class)] +#[UsesClass(DevToolsServiceProvider::class)] +#[UsesClass(DevToolsEnvironment::class)] +#[UsesClass(RuntimeEnvironment::class)] #[CoversClass(DocsCommand::class)] #[UsesClass(DevToolsPathResolver::class)] #[UsesClass(ManagedWorkspace::class)] @@ -56,6 +65,7 @@ final class DocsCommandTest extends TestCase { use ProphecyTrait; + use UsesContainerFactory; private ObjectProphecy $processBuilder; @@ -91,6 +101,7 @@ protected function setUp(): void $this->composer = $this->prophesize(ComposerJsonInterface::class); $this->projectCapabilitiesResolver = $this->prophesize(ProjectCapabilitiesResolverInterface::class); $this->logger = $this->prophesize(LoggerInterface::class); + $this->setContainerEntry(LoggerInterface::class, $this->logger->reveal()); $this->input = $this->prophesize(InputInterface::class); $this->output = $this->prophesize(OutputInterface::class); $this->process = $this->prophesize(Process::class); @@ -165,7 +176,6 @@ protected function setUp(): void $this->filesystem->reveal(), $this->composer->reveal(), $this->projectCapabilitiesResolver->reveal(), - $this->logger->reveal(), ); } @@ -179,7 +189,7 @@ public function executeWillSkipWhenGuideAndApiSourcesAreMissing(): void ->willReturn(new ProjectCapabilities([], null, false, false, false, false)); $this->processQueue->add(Argument::cetera()) ->shouldNotBeCalled(); - $this->logger->info('Generating API documentation...', Argument::that( + $this->logger->log('info', 'Generating API documentation...', Argument::that( static fn(array $context): bool => $context['input'] instanceof InputInterface )) ->shouldBeCalled(); @@ -206,7 +216,7 @@ public function executeWillFailWhenCustomGuideSourceDoesNotExist(): void ->willReturn(new ProjectCapabilities(['src/'], 'FastForward\\DevTools', false, false, false, true)); $this->processQueue->add(Argument::cetera()) ->shouldNotBeCalled(); - $this->logger->info('Generating API documentation...', Argument::that( + $this->logger->log('info', 'Generating API documentation...', Argument::that( static fn(array $context): bool => $context['input'] instanceof InputInterface )) ->shouldBeCalled(); @@ -240,7 +250,7 @@ public function executeWillTreatEquivalentDefaultGuideSourcesAsDefault(string $s $this->processQueue->run($this->output->reveal()) ->willReturn(DocsCommand::SUCCESS) ->shouldBeCalled(); - $this->logger->info('Generating API documentation...', Argument::that( + $this->logger->log('info', 'Generating API documentation...', Argument::that( static fn(array $context): bool => $context['input'] instanceof InputInterface )) ->shouldBeCalled(); @@ -272,7 +282,7 @@ public function executeWillReturnSuccessWhenProcessQueueSucceeds(): void $this->processQueue->run($this->output->reveal()) ->willReturn(DocsCommand::SUCCESS) ->shouldBeCalled(); - $this->logger->info('Generating API documentation...', Argument::that( + $this->logger->log('info', 'Generating API documentation...', Argument::that( static fn(array $context): bool => $context['input'] instanceof InputInterface )) ->shouldBeCalled(); @@ -286,6 +296,36 @@ public function executeWillReturnSuccessWhenProcessQueueSucceeds(): void self::assertSame(DocsCommand::SUCCESS, $this->executeCommand()); } + /** + * @return void + */ + #[Test] + public function executeWillSuppressProgressLogWhenJsonIsRequested(): void + { + $this->input->getOption('json') + ->willReturn(true); + $this->input->getOption('pretty-json') + ->willReturn(false); + $this->filesystem->dumpFile('phpdocumentor.xml', '', '/repo/.dev-tools/cache/phpdoc') + ->shouldBeCalled(); + $this->processBuilder->withArgument('--cache-folder', '/repo/.dev-tools/cache/phpdoc') + ->willReturn($this->processBuilder->reveal()) + ->shouldBeCalled(); + $this->processQueue->add($this->process->reveal(), Argument::cetera()) + ->shouldBeCalled(); + $this->processQueue->run(Argument::type(OutputInterface::class)) + ->willReturn(DocsCommand::SUCCESS) + ->shouldBeCalled(); + $this->logger->log( + 'info', + 'API documentation generated successfully.', + Argument::that(static fn(array $context): bool => $context['input'] instanceof InputInterface + && $context['output'] instanceof OutputInterface), + )->shouldBeCalled(); + + self::assertSame(DocsCommand::SUCCESS, $this->executeCommand()); + } + /** * @return void */ diff --git a/tests/Console/Command/FundingCommandTest.php b/tests/Console/Command/FundingCommandTest.php index 47e658440b..6a362a8ffb 100644 --- a/tests/Console/Command/FundingCommandTest.php +++ b/tests/Console/Command/FundingCommandTest.php @@ -23,6 +23,7 @@ use Symfony\Component\Console\Style\SymfonyStyle; use FastForward\DevTools\Console\Command\FundingCommand; use FastForward\DevTools\Console\Command\Traits\LogsCommandResults; +use FastForward\DevTools\Console\Output\GithubActionOutput; use FastForward\DevTools\Filesystem\FilesystemInterface; use FastForward\DevTools\Funding\ComposerFundingCodec; use FastForward\DevTools\Funding\FundingProfile; @@ -32,6 +33,11 @@ use FastForward\DevTools\Process\ProcessQueueInterface; use FastForward\DevTools\Resource\FileDiff; use FastForward\DevTools\Resource\FileDiffer; +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Environment\Environment as DevToolsEnvironment; +use FastForward\DevTools\Environment\RuntimeEnvironment; +use FastForward\DevTools\Path\DevToolsPathResolver; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -41,6 +47,7 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; +use FastForward\DevTools\Tests\Container\UsesContainerFactory; use ReflectionMethod; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -49,7 +56,13 @@ use function Safe\json_decode; +#[UsesClass(ContainerFactory::class)] +#[UsesClass(DevToolsPathResolver::class)] +#[UsesClass(DevToolsServiceProvider::class)] +#[UsesClass(DevToolsEnvironment::class)] +#[UsesClass(RuntimeEnvironment::class)] #[CoversClass(FundingCommand::class)] +#[UsesClass(GithubActionOutput::class)] #[UsesClass(FileDiff::class)] #[UsesClass(ComposerFundingCodec::class)] #[UsesClass(FundingProfile::class)] @@ -59,6 +72,7 @@ final class FundingCommandTest extends TestCase { use ProphecyTrait; + use UsesContainerFactory; private ObjectProphecy $filesystem; @@ -87,6 +101,11 @@ protected function setUp(): void { $this->filesystem = $this->prophesize(FilesystemInterface::class); $this->input = $this->prophesize(InputInterface::class); + + $this->input->getOption('json') + ->willReturn(false); + $this->input->getOption('pretty-json') + ->willReturn(false); $this->output = $this->prophesize(OutputInterface::class); $this->fileDiffer = $this->prophesize(FileDiffer::class); $this->processBuilder = $this->prophesize(ProcessBuilderInterface::class); @@ -94,13 +113,14 @@ protected function setUp(): void $this->normalizeProcess = $this->prophesize(Process::class); $this->io = $this->prophesize(SymfonyStyle::class); $this->logger = $this->prophesize(LoggerInterface::class); + $this->setContainerEntry(LoggerInterface::class, $this->logger->reveal()); $this->output->isDecorated() ->willReturn(false); $this->output->writeln(Argument::any()); $this->fileDiffer->formatForConsole(Argument::cetera())->willReturn(null); - $this->logger->info(Argument::cetera())->will(static function (): void {}); + $this->logger->log('info', Argument::cetera())->will(static function (): void {}); $this->logger->log(Argument::cetera())->will(static function (): void {}); - $this->logger->notice(Argument::cetera())->will(static function (): void {}); + $this->logger->log('notice', Argument::cetera())->will(static function (): void {}); $this->logger->error(Argument::cetera())->will(static function (): void {}); $this->input->getOption('composer-file') ->willReturn('composer.json'); @@ -133,7 +153,6 @@ protected function setUp(): void $this->fileDiffer->reveal(), $this->processBuilder->reveal(), $this->processQueue->reveal(), - $this->logger->reveal(), $this->io->reveal(), ); } @@ -346,11 +365,12 @@ public function executeWillReturnSuccessWhenComposerFileDoesNotExist(): void { $this->filesystem->exists('composer.json') ->willReturn(false); - $this->logger->info('Synchronizing funding metadata...', [ + $this->logger->log('info', 'Synchronizing funding metadata...', [ 'input' => $this->input->reveal(), ]) ->shouldBeCalledOnce(); - $this->logger->notice( + $this->logger->log( + 'notice', 'Composer file {composer_file} does not exist. Skipping funding synchronization.', [ 'input' => $this->input->reveal(), @@ -492,7 +512,7 @@ public function executeWillSkipComposerWriteWhenInteractiveConfirmationIsDecline $fundingYaml, 'Updating managed file .github/FUNDING.yml from generated funding metadata synchronization.', )->willReturn(new FileDiff(FileDiff::STATUS_UNCHANGED, 'Funding unchanged'))->shouldBeCalledOnce(); - $this->logger->notice('Skipped updating {composer_file}.', Argument::type('array')) + $this->logger->log('notice', 'Skipped updating {composer_file}.', Argument::type('array')) ->shouldBeCalledOnce(); $this->logger->log( 'notice', @@ -647,7 +667,8 @@ public function executeWillSkipFundingFileSynchronizationWhenNoSupportedFundingM $composerContents, 'Updating managed file composer.json from generated funding metadata synchronization.', )->willReturn(new FileDiff(FileDiff::STATUS_UNCHANGED, 'Composer unchanged'))->shouldBeCalledOnce(); - $this->logger->notice( + $this->logger->log( + 'notice', 'No supported funding metadata found. Skipping .github/FUNDING.yml synchronization.', [ 'input' => $this->input->reveal(), diff --git a/tests/Console/Command/GitAttributesCommandTest.php b/tests/Console/Command/GitAttributesCommandTest.php index f25f85a219..94230998a9 100644 --- a/tests/Console/Command/GitAttributesCommandTest.php +++ b/tests/Console/Command/GitAttributesCommandTest.php @@ -34,6 +34,11 @@ use FastForward\DevTools\GitAttributes\WriterInterface; use FastForward\DevTools\Resource\FileDiff; use FastForward\DevTools\Resource\FileDiffer; +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Environment\Environment as DevToolsEnvironment; +use FastForward\DevTools\Environment\RuntimeEnvironment; +use FastForward\DevTools\Path\DevToolsPathResolver; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -42,18 +47,25 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; +use FastForward\DevTools\Tests\Container\UsesContainerFactory; use ReflectionMethod; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use function Safe\getcwd; +#[UsesClass(ContainerFactory::class)] +#[UsesClass(DevToolsPathResolver::class)] +#[UsesClass(DevToolsServiceProvider::class)] +#[UsesClass(DevToolsEnvironment::class)] +#[UsesClass(RuntimeEnvironment::class)] #[CoversClass(GitAttributesCommand::class)] #[UsesClass(FileDiff::class)] #[UsesTrait(LogsCommandResults::class)] final class GitAttributesCommandTest extends TestCase { use ProphecyTrait; + use UsesContainerFactory; /** * @var ObjectProphecy @@ -133,18 +145,24 @@ protected function setUp(): void $this->composerJson = $this->prophesize(ComposerJsonInterface::class); $this->filesystem = $this->prophesize(FilesystemInterface::class); $this->input = $this->prophesize(InputInterface::class); + + $this->input->getOption('json') + ->willReturn(false); + $this->input->getOption('pretty-json') + ->willReturn(false); $this->output = $this->prophesize(OutputInterface::class); $this->fileDiffer = $this->prophesize(FileDiffer::class); $this->logger = $this->prophesize(LoggerInterface::class); + $this->setContainerEntry(LoggerInterface::class, $this->logger->reveal()); $this->io = $this->prophesize(SymfonyStyle::class); $this->output->isDecorated() ->willReturn(false); $this->output->writeln(Argument::any()); $this->fileDiffer->formatForConsole(Argument::cetera()) ->willReturn(null); - $this->logger->info(Argument::cetera())->will(static function (): void {}); + $this->logger->log('info', Argument::cetera())->will(static function (): void {}); $this->logger->log(Argument::cetera())->will(static function (): void {}); - $this->logger->notice(Argument::cetera())->will(static function (): void {}); + $this->logger->log('notice', Argument::cetera())->will(static function (): void {}); $this->logger->error(Argument::cetera())->will(static function (): void {}); $this->input->getOption('dry-run') ->willReturn(false); @@ -168,7 +186,6 @@ protected function setUp(): void $this->composerJson->reveal(), $this->filesystem->reveal(), $this->fileDiffer->reveal(), - $this->logger->reveal(), $this->io->reveal(), ); } @@ -236,11 +253,12 @@ public function executeWillReturnSuccessAndWriteMergedGitattributes(): void $this->writer->write($gitattributesPath, "custom-entry\n/.github/ export-ignore") ->shouldBeCalledOnce(); - $this->logger->info('Synchronizing .gitattributes export-ignore rules...', [ + $this->logger->log('info', 'Synchronizing .gitattributes export-ignore rules...', [ 'input' => $this->input->reveal(), ]) ->shouldBeCalled(); - $this->logger->notice( + $this->logger->log( + 'notice', 'Updating managed file ' . $gitattributesPath . ' from generated .gitattributes synchronization.', Argument::type('array'), )->shouldBeCalled(); @@ -335,11 +353,12 @@ public function executeWithNoCandidatesWillSkipSynchronization(): void $this->writer->write(Argument::cetera()) ->shouldNotBeCalled(); - $this->logger->info('Synchronizing .gitattributes export-ignore rules...', [ + $this->logger->log('info', 'Synchronizing .gitattributes export-ignore rules...', [ 'input' => $this->input->reveal(), ]) ->shouldBeCalled(); - $this->logger->notice( + $this->logger->log( + 'notice', 'No candidate paths found in repository. Skipping .gitattributes sync.', [ 'input' => $this->input->reveal(), @@ -395,7 +414,7 @@ public function executeWillReturnFailureInCheckModeWhenGitattributesWouldChange( $this->fileDiffer->formatForConsole('@@ diff @@', false) ->willReturn('@@ diff @@') ->shouldBeCalledOnce(); - $this->logger->notice('@@ diff @@', Argument::type('array')) + $this->logger->log('notice', '@@ diff @@', Argument::type('array')) ->shouldBeCalledOnce(); $this->logger->error('.gitattributes requires synchronization updates.', Argument::type('array')) ->shouldBeCalledOnce(); @@ -488,7 +507,7 @@ public function executeWillSkipWritingWhenInteractiveConfirmationIsDeclined(): v $this->io->askQuestion(Argument::type(ConfirmationQuestion::class)) ->willReturn(false) ->shouldBeCalledOnce(); - $this->logger->notice('Skipped updating {gitattributes_path}.', Argument::type('array')) + $this->logger->log('notice', 'Skipped updating {gitattributes_path}.', Argument::type('array')) ->shouldBeCalledOnce(); $this->logger->log('notice', '.gitattributes synchronization was skipped.', Argument::type('array')) ->shouldBeCalledOnce(); diff --git a/tests/Console/Command/GitHooksCommandTest.php b/tests/Console/Command/GitHooksCommandTest.php index 17d014b5b9..09e97b0643 100644 --- a/tests/Console/Command/GitHooksCommandTest.php +++ b/tests/Console/Command/GitHooksCommandTest.php @@ -28,12 +28,17 @@ use FastForward\DevTools\Path\WorkingProjectPathResolver; use FastForward\DevTools\Resource\FileDiff; use FastForward\DevTools\Resource\FileDiffer; +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Environment\Environment as DevToolsEnvironment; +use FastForward\DevTools\Environment\RuntimeEnvironment; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\Attributes\UsesTrait; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; +use FastForward\DevTools\Tests\Container\UsesContainerFactory; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; @@ -53,6 +58,10 @@ use function Safe\unlink; use function Safe\rmdir; +#[UsesClass(ContainerFactory::class)] +#[UsesClass(DevToolsServiceProvider::class)] +#[UsesClass(DevToolsEnvironment::class)] +#[UsesClass(RuntimeEnvironment::class)] #[CoversClass(GitHooksCommand::class)] #[UsesClass(DevToolsPathResolver::class)] #[UsesClass(WorkingProjectPathResolver::class)] @@ -62,6 +71,7 @@ final class GitHooksCommandTest extends TestCase { use ProphecyTrait; + use UsesContainerFactory; private ObjectProphecy $filesystem; @@ -96,17 +106,23 @@ protected function setUp(): void $this->fileLocator = $this->prophesize(FileLocatorInterface::class); $this->finderFactory = $this->prophesize(FinderFactoryInterface::class); $this->input = $this->prophesize(InputInterface::class); + + $this->input->getOption('json') + ->willReturn(false); + $this->input->getOption('pretty-json') + ->willReturn(false); $this->output = $this->prophesize(OutputInterface::class); $this->fileDiffer = $this->prophesize(FileDiffer::class); $this->logger = $this->prophesize(LoggerInterface::class); + $this->setContainerEntry(LoggerInterface::class, $this->logger->reveal()); $this->io = $this->prophesize(SymfonyStyle::class); $this->output->isDecorated() ->willReturn(false); $this->fileDiffer->formatForConsole(Argument::cetera()) ->willReturn(null); - $this->logger->info(Argument::cetera())->will(static function (): void {}); + $this->logger->log('info', Argument::cetera())->will(static function (): void {}); $this->logger->log(Argument::cetera())->will(static function (): void {}); - $this->logger->notice(Argument::cetera())->will(static function (): void {}); + $this->logger->log('notice', Argument::cetera())->will(static function (): void {}); $this->logger->error(Argument::cetera())->will(static function (): void {}); $this->input->getOption('dry-run') ->willReturn(false); @@ -127,7 +143,6 @@ protected function setUp(): void $this->finderFactory->reveal(), new HookContentRenderer(), $this->fileDiffer->reveal(), - $this->logger->reveal(), $this->io->reveal(), ); } @@ -270,7 +285,8 @@ public function executeWillSkipExistingHooksWhenNoOverwriteIsRequested(): void ->willReturn('/app/.git/hooks'); $this->filesystem->exists('/app/.git/hooks/post-merge') ->willReturn(true); - $this->logger->notice( + $this->logger->log( + 'notice', 'Skipped existing {hook_name} hook.', [ 'input' => $this->input->reveal(), @@ -319,7 +335,8 @@ public function executeWillReturnFailureInCheckModeWhenHookWouldChange(): void $this->fileDiffer->formatForConsole("@@ -1 +1 @@\n-old\n+new", false) ->willReturn("@@ -1 +1 @@\n-old\n+new") ->shouldBeCalledOnce(); - $this->logger->notice( + $this->logger->log( + 'notice', 'Changed summary', [ 'input' => $this->input->reveal(), @@ -328,7 +345,8 @@ public function executeWillReturnFailureInCheckModeWhenHookWouldChange(): void ], ) ->shouldBeCalledOnce(); - $this->logger->notice( + $this->logger->log( + 'notice', "@@ -1 +1 @@\n-old\n+new", [ 'input' => $this->input->reveal(), @@ -383,7 +401,8 @@ public function executeWillSkipReplacingHookWhenInteractiveConfirmationIsDecline $this->io->askQuestion(Argument::type(ConfirmationQuestion::class)) ->willReturn(false) ->shouldBeCalledOnce(); - $this->logger->notice( + $this->logger->log( + 'notice', 'Skipped replacing {hook_path}.', [ 'input' => $this->input->reveal(), diff --git a/tests/Console/Command/GitIgnoreCommandTest.php b/tests/Console/Command/GitIgnoreCommandTest.php index 4bebec70a8..a916c5a009 100644 --- a/tests/Console/Command/GitIgnoreCommandTest.php +++ b/tests/Console/Command/GitIgnoreCommandTest.php @@ -30,6 +30,11 @@ use FastForward\DevTools\Resource\FileDiff; use FastForward\DevTools\Resource\FileDiffer; use Psr\Log\LoggerInterface; +use FastForward\DevTools\Tests\Container\UsesContainerFactory; +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Environment\Environment as DevToolsEnvironment; +use FastForward\DevTools\Environment\RuntimeEnvironment; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -42,12 +47,17 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +#[UsesClass(ContainerFactory::class)] +#[UsesClass(DevToolsServiceProvider::class)] +#[UsesClass(DevToolsEnvironment::class)] +#[UsesClass(RuntimeEnvironment::class)] #[CoversClass(GitIgnoreCommand::class)] #[UsesClass(DevToolsPathResolver::class)] #[UsesClass(FileDiff::class)] final class GitIgnoreCommandTest extends TestCase { use ProphecyTrait; + use UsesContainerFactory; /** * @var ObjectProphecy @@ -122,16 +132,22 @@ protected function setUp(): void $this->reader = $this->prophesize(ReaderInterface::class); $this->writer = $this->prophesize(WriterInterface::class); $this->input = $this->prophesize(InputInterface::class); + + $this->input->getOption('json') + ->willReturn(false); + $this->input->getOption('pretty-json') + ->willReturn(false); $this->output = $this->prophesize(OutputInterface::class); $this->fileDiffer = $this->prophesize(FileDiffer::class); $this->logger = $this->prophesize(LoggerInterface::class); + $this->setContainerEntry(LoggerInterface::class, $this->logger->reveal()); $this->io = $this->prophesize(SymfonyStyle::class); $this->output->isDecorated() ->willReturn(false); $this->fileDiffer->formatForConsole(Argument::cetera()) ->willReturn(null); - $this->logger->info(Argument::cetera())->will(static function (): void {}); - $this->logger->notice(Argument::cetera())->will(static function (): void {}); + $this->logger->log('info', Argument::cetera())->will(static function (): void {}); + $this->logger->log('notice', Argument::cetera())->will(static function (): void {}); $this->logger->error(Argument::cetera())->will(static function (): void {}); $this->gitIgnoreSource = $this->prophesize(GitIgnoreInterface::class); $this->gitIgnoreTarget = $this->prophesize(GitIgnoreInterface::class); @@ -181,7 +197,6 @@ protected function setUp(): void $this->writer->reveal(), $this->fileLocator->reveal(), $this->fileDiffer->reveal(), - $this->logger->reveal(), $this->io->reveal(), ); } @@ -221,7 +236,7 @@ public function executeWillReturnSuccessWhenMergeSucceeds(): void $this->writer->write($this->gitIgnoreMerged->reveal()) ->shouldBeCalled(); - $this->logger->info('Merging .gitignore files...', [ + $this->logger->log('info', 'Merging .gitignore files...', [ 'input' => $this->input->reveal(), ]) ->shouldBeCalled(); diff --git a/tests/Console/Command/LicenseCommandTest.php b/tests/Console/Command/LicenseCommandTest.php index 598c680bc6..e2ec9d20c3 100644 --- a/tests/Console/Command/LicenseCommandTest.php +++ b/tests/Console/Command/LicenseCommandTest.php @@ -31,6 +31,12 @@ use FastForward\DevTools\Resource\FileDiff; use FastForward\DevTools\Resource\FileDiffer; use Psr\Log\LoggerInterface; +use FastForward\DevTools\Tests\Container\UsesContainerFactory; +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Environment\Environment as DevToolsEnvironment; +use FastForward\DevTools\Environment\RuntimeEnvironment; +use FastForward\DevTools\Path\DevToolsPathResolver; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -45,6 +51,11 @@ use function Safe\getcwd; +#[UsesClass(ContainerFactory::class)] +#[UsesClass(DevToolsPathResolver::class)] +#[UsesClass(DevToolsServiceProvider::class)] +#[UsesClass(DevToolsEnvironment::class)] +#[UsesClass(RuntimeEnvironment::class)] #[CoversClass(LicenseCommand::class)] #[UsesClass(FileDiff::class)] #[UsesClass(Resolver::class)] @@ -54,6 +65,7 @@ final class LicenseCommandTest extends TestCase { use ProphecyTrait; + use UsesContainerFactory; /** * @var ObjectProphecy @@ -96,9 +108,15 @@ protected function setUp(): void $this->generator = $this->prophesize(GeneratorInterface::class); $this->filesystem = $this->prophesize(FilesystemInterface::class); $this->input = $this->prophesize(InputInterface::class); + + $this->input->getOption('json') + ->willReturn(false); + $this->input->getOption('pretty-json') + ->willReturn(false); $this->output = $this->prophesize(OutputInterface::class); $this->fileDiffer = $this->prophesize(FileDiffer::class); $this->logger = $this->prophesize(LoggerInterface::class); + $this->setContainerEntry(LoggerInterface::class, $this->logger->reveal()); $this->io = $this->prophesize(SymfonyStyle::class); $this->output->isDecorated() ->willReturn(false); @@ -112,16 +130,15 @@ protected function setUp(): void ->willReturn(false); $this->fileDiffer->formatForConsole(Argument::cetera()) ->willReturn(null); - $this->logger->info(Argument::cetera())->will(static function (): void {}); + $this->logger->log('info', Argument::cetera())->will(static function (): void {}); $this->logger->log(Argument::cetera())->will(static function (): void {}); - $this->logger->notice(Argument::cetera())->will(static function (): void {}); + $this->logger->log('notice', Argument::cetera())->will(static function (): void {}); $this->logger->error(Argument::cetera())->will(static function (): void {}); $this->command = new LicenseCommand( $this->generator->reveal(), $this->filesystem->reveal(), $this->fileDiffer->reveal(), - $this->logger->reveal(), $this->io->reveal(), ); } @@ -172,7 +189,8 @@ public function executeWillReturnSuccessAndWriteInfo(): void $this->filesystem->dumpFile($targetPath, 'MIT License content') ->shouldBeCalledOnce(); - $this->logger->notice( + $this->logger->log( + 'notice', 'Managed file ' . $targetPath . ' will be created from generated LICENSE content.', [ 'input' => $this->input->reveal(), @@ -221,7 +239,8 @@ public function executeWillSkipWhenLicenseFileExists(): void 'Target ' . $targetPath . ' already matches source generated LICENSE content; overwrite skipped.', ))->shouldBeCalledOnce(); - $this->logger->notice( + $this->logger->log( + 'notice', 'Target ' . $targetPath . ' already matches source generated LICENSE content; overwrite skipped.', [ 'input' => $this->input->reveal(), @@ -249,7 +268,8 @@ public function executeWillHandleUnsupportedLicense(): void $this->generator->generateContent() ->willReturn(null); - $this->logger->notice( + $this->logger->log( + 'notice', 'No supported license found in composer.json or license is unsupported. Skipping LICENSE generation.', [ 'input' => $this->input->reveal(), @@ -374,7 +394,8 @@ public function executeWillSkipWritingWhenInteractiveConfirmationIsDeclined(): v $this->io->askQuestion(Argument::type(ConfirmationQuestion::class)) ->willReturn(false) ->shouldBeCalledOnce(); - $this->logger->notice( + $this->logger->log( + 'notice', 'Skipped updating {target_path}.', [ 'input' => $this->input->reveal(), diff --git a/tests/Console/Command/MetricsCommandTest.php b/tests/Console/Command/MetricsCommandTest.php index dbd2d84255..d6952a3bb9 100644 --- a/tests/Console/Command/MetricsCommandTest.php +++ b/tests/Console/Command/MetricsCommandTest.php @@ -27,6 +27,10 @@ use FastForward\DevTools\Path\ManagedWorkspace; use FastForward\DevTools\Path\WorkingProjectPathResolver; use FastForward\DevTools\Project\ProjectCapabilities; +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Environment\Environment as DevToolsEnvironment; +use FastForward\DevTools\Environment\RuntimeEnvironment; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -36,6 +40,7 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; +use FastForward\DevTools\Tests\Container\UsesContainerFactory; use ReflectionMethod; use Symfony\Component\Console\Formatter\OutputFormatter; use Symfony\Component\Console\Input\InputInterface; @@ -44,6 +49,10 @@ use function Safe\putenv; +#[UsesClass(ContainerFactory::class)] +#[UsesClass(DevToolsServiceProvider::class)] +#[UsesClass(DevToolsEnvironment::class)] +#[UsesClass(RuntimeEnvironment::class)] #[CoversClass(MetricsCommand::class)] #[UsesClass(DevToolsPathResolver::class)] #[UsesClass(ManagedWorkspace::class)] @@ -53,6 +62,7 @@ final class MetricsCommandTest extends TestCase { use ProphecyTrait; + use UsesContainerFactory; private ObjectProphecy $processBuilder; @@ -76,6 +86,7 @@ protected function setUp(): void $this->processBuilder = $this->prophesize(ProcessBuilderInterface::class); $this->processQueue = $this->prophesize(ProcessQueueInterface::class); $this->logger = $this->prophesize(LoggerInterface::class); + $this->setContainerEntry(LoggerInterface::class, $this->logger->reveal()); $this->input = $this->prophesize(InputInterface::class); $this->output = $this->prophesize(OutputInterface::class); $this->process = $this->prophesize(Process::class); @@ -107,11 +118,7 @@ protected function setUp(): void && '-ddefault_socket_timeout=1' === $command[2] && DevToolsPathResolver::getPreferredToolBinaryPath('phpmetrics') === $command[3])) ->willReturn($this->process->reveal()); - $this->command = new MetricsCommand( - $this->processBuilder->reveal(), - $this->processQueue->reveal(), - $this->logger->reveal(), - ); + $this->command = new MetricsCommand($this->processBuilder->reveal(), $this->processQueue->reveal()); } /** @@ -132,7 +139,7 @@ public function executeWillReturnSuccessWhenProcessQueueSucceeds(): void $this->processQueue->run($this->output->reveal()) ->willReturn(MetricsCommand::SUCCESS) ->shouldBeCalled(); - $this->logger->info('Running code metrics analysis...', Argument::that( + $this->logger->log('info', 'Running code metrics analysis...', Argument::that( static fn(array $context): bool => $context['input'] instanceof InputInterface )) ->shouldBeCalled(); @@ -156,7 +163,7 @@ public function executeWillReturnFailureWhenProcessQueueFails(): void $this->processQueue->run($this->output->reveal()) ->willReturn(MetricsCommand::FAILURE) ->shouldBeCalled(); - $this->logger->info('Running code metrics analysis...', Argument::that( + $this->logger->log('info', 'Running code metrics analysis...', Argument::that( static fn(array $context): bool => $context['input'] instanceof InputInterface )) ->shouldBeCalled(); @@ -186,6 +193,12 @@ public function executeWillRunPhpMetricsInQuietModeWhenJsonIsRequested(): void $this->processQueue->run(Argument::type(OutputInterface::class)) ->willReturn(MetricsCommand::SUCCESS) ->shouldBeCalled(); + $this->logger->log( + 'info', + 'Code metrics analysis completed successfully.', + Argument::that(static fn(array $context): bool => $context['input'] instanceof InputInterface + && $context['output'] instanceof OutputInterface), + )->shouldBeCalled(); self::assertSame(MetricsCommand::SUCCESS, $this->executeCommand()); } @@ -216,11 +229,7 @@ public function configureWillExcludeCustomRelativeWorkspaceByDefault(): void { putenv(ManagedWorkspace::ENV_WORKSPACE_DIR . '=.artifacts'); - $command = new MetricsCommand( - $this->processBuilder->reveal(), - $this->processQueue->reveal(), - $this->logger->reveal(), - ); + $command = new MetricsCommand($this->processBuilder->reveal(), $this->processQueue->reveal()); self::assertSame( 'vendor,tmp,cache,spec,build,.dev-tools,backup,resources,.artifacts', @@ -258,7 +267,7 @@ public function executeWillRunEvenWhenNoTestsOrPhpSourceExist(): void $this->processQueue->run($this->output->reveal()) ->willReturn(MetricsCommand::SUCCESS) ->shouldBeCalled(); - $this->logger->info('Running code metrics analysis...', Argument::that( + $this->logger->log('info', 'Running code metrics analysis...', Argument::that( static fn(array $context): bool => $context['input'] instanceof InputInterface ))->shouldBeCalled(); $this->logger->log( diff --git a/tests/Console/Command/PhpDocCommandTest.php b/tests/Console/Command/PhpDocCommandTest.php index aaebc09de1..f1b1f65b37 100644 --- a/tests/Console/Command/PhpDocCommandTest.php +++ b/tests/Console/Command/PhpDocCommandTest.php @@ -33,6 +33,10 @@ use FastForward\DevTools\Process\ProcessQueueInterface; use FastForward\DevTools\Path\ManagedWorkspace; use FastForward\DevTools\Path\WorkingProjectPathResolver; +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Environment\Environment as DevToolsEnvironment; +use FastForward\DevTools\Environment\RuntimeEnvironment; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -43,6 +47,7 @@ use Prophecy\Prophecy\ObjectProphecy; use Psr\Clock\ClockInterface; use Psr\Log\LoggerInterface; +use FastForward\DevTools\Tests\Container\UsesContainerFactory; use ReflectionMethod; use Symfony\Component\Config\FileLocatorInterface; use Symfony\Component\Console\Formatter\OutputFormatter; @@ -51,6 +56,10 @@ use Symfony\Component\Process\Process; use Twig\Environment; +#[UsesClass(ContainerFactory::class)] +#[UsesClass(DevToolsServiceProvider::class)] +#[UsesClass(DevToolsEnvironment::class)] +#[UsesClass(RuntimeEnvironment::class)] #[CoversClass(PhpDocCommand::class)] #[UsesClass(Author::class)] #[UsesClass(Support::class)] @@ -61,6 +70,7 @@ final class PhpDocCommandTest extends TestCase { use ProphecyTrait; + use UsesContainerFactory; private ObjectProphecy $processBuilder; @@ -99,6 +109,7 @@ protected function setUp(): void $this->renderer = $this->prophesize(Environment::class); $this->clock = $this->prophesize(ClockInterface::class); $this->logger = $this->prophesize(LoggerInterface::class); + $this->setContainerEntry(LoggerInterface::class, $this->logger->reveal()); $this->input = $this->prophesize(InputInterface::class); $this->output = $this->prophesize(OutputInterface::class); $this->process = $this->prophesize(Process::class); @@ -169,7 +180,6 @@ protected function setUp(): void $this->filesystem->reveal(), $this->renderer->reveal(), $this->clock->reveal(), - $this->logger->reveal(), ); } @@ -197,11 +207,13 @@ public function executeWillCreateDocHeaderAndRunPhpDocProcesses(): void $this->processQueue->run($this->output->reveal()) ->willReturn(PhpDocCommand::SUCCESS) ->shouldBeCalled(); - $this->logger->info('Checking and fixing PHPDocs...', Argument::that( + $this->logger->log('info', 'Checking and fixing PHPDocs...', Argument::that( static fn(array $context): bool => $context['input'] instanceof InputInterface )) ->shouldBeCalled(); - $this->logger->info('Created .docheader from repository template.') + $this->logger->log('info', 'Created .docheader from repository template.', Argument::that( + static fn(array $context): bool => $context['input'] instanceof InputInterface + )) ->shouldBeCalled(); $this->logger->log( 'info', @@ -247,12 +259,14 @@ public function executeWillHandleDumpFileExceptionAndContinueRunningProcesses(): $this->processQueue->run($this->output->reveal()) ->willReturn(PhpDocCommand::FAILURE) ->shouldBeCalled(); - $this->logger->info('Checking and fixing PHPDocs...', Argument::that( + $this->logger->log('info', 'Checking and fixing PHPDocs...', Argument::that( static fn(array $context): bool => $context['input'] instanceof InputInterface )) ->shouldBeCalled(); - $this->logger->warning( - 'Skipping .docheader creation because the destination file could not be written.' + $this->logger->log( + 'warning', + 'Skipping .docheader creation because the destination file could not be written.', + Argument::that(static fn(array $context): bool => $context['input'] instanceof InputInterface), )->shouldBeCalled(); $this->logger->error( 'PHPDoc checks failed.', @@ -289,6 +303,12 @@ public function executeWillRequestStructuredOutputAndDisableProgressWhenJsonIsRe $this->processQueue->run(Argument::type(OutputInterface::class)) ->willReturn(PhpDocCommand::SUCCESS) ->shouldBeCalled(); + $this->logger->log( + 'info', + 'PHPDoc checks completed successfully.', + Argument::that(static fn(array $context): bool => $context['input'] instanceof InputInterface + && $context['output'] instanceof OutputInterface), + )->shouldBeCalled(); self::assertSame(PhpDocCommand::SUCCESS, $this->invokeExecute()); } diff --git a/tests/Console/Command/RefactorCommandTest.php b/tests/Console/Command/RefactorCommandTest.php index a8ea0934b0..f28d5cd9dc 100644 --- a/tests/Console/Command/RefactorCommandTest.php +++ b/tests/Console/Command/RefactorCommandTest.php @@ -25,6 +25,10 @@ use FastForward\DevTools\Path\WorkingProjectPathResolver; use FastForward\DevTools\Process\ProcessBuilderInterface; use FastForward\DevTools\Process\ProcessQueueInterface; +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Environment\Environment as DevToolsEnvironment; +use FastForward\DevTools\Environment\RuntimeEnvironment; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -34,6 +38,7 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; +use FastForward\DevTools\Tests\Container\UsesContainerFactory; use ReflectionMethod; use Symfony\Component\Config\FileLocatorInterface; use Symfony\Component\Console\Formatter\OutputFormatter; @@ -41,6 +46,10 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Process\Process; +#[UsesClass(ContainerFactory::class)] +#[UsesClass(DevToolsServiceProvider::class)] +#[UsesClass(DevToolsEnvironment::class)] +#[UsesClass(RuntimeEnvironment::class)] #[CoversClass(RefactorCommand::class)] #[UsesClass(DevToolsPathResolver::class)] #[UsesClass(WorkingProjectPathResolver::class)] @@ -48,6 +57,7 @@ final class RefactorCommandTest extends TestCase { use ProphecyTrait; + use UsesContainerFactory; private ObjectProphecy $fileLocator; @@ -77,6 +87,7 @@ protected function setUp(): void $this->input = $this->prophesize(InputInterface::class); $this->output = $this->prophesize(OutputInterface::class); $this->logger = $this->prophesize(LoggerInterface::class); + $this->setContainerEntry(LoggerInterface::class, $this->logger->reveal()); $this->input->getOption('fix') ->willReturn(false); @@ -103,7 +114,6 @@ protected function setUp(): void $this->fileLocator->reveal(), $this->processBuilder->reveal(), $this->processQueue->reveal(), - $this->logger->reveal(), ); } @@ -119,7 +129,7 @@ public function executeWillReturnSuccessWhenProcessQueueSucceeds(): void $this->processQueue->run($this->output->reveal()) ->willReturn(RefactorCommand::SUCCESS) ->shouldBeCalled(); - $this->logger->info('Running Rector for code refactoring...', Argument::that( + $this->logger->log('info', 'Running Rector for code refactoring...', Argument::that( static fn(array $context): bool => $context['input'] instanceof InputInterface )) ->shouldBeCalled(); @@ -142,7 +152,7 @@ public function executeWillReturnFailureWhenProcessQueueFails(): void $this->processQueue->run($this->output->reveal()) ->willReturn(RefactorCommand::FAILURE) ->shouldBeCalled(); - $this->logger->info('Running Rector for code refactoring...', Argument::that( + $this->logger->log('info', 'Running Rector for code refactoring...', Argument::that( static fn(array $context): bool => $context['input'] instanceof InputInterface )) ->shouldBeCalled(); @@ -174,6 +184,12 @@ public function executeWillRequestJsonOutputAndDisableProgressWhenJsonIsRequeste $this->processQueue->run(Argument::type(OutputInterface::class)) ->willReturn(RefactorCommand::SUCCESS) ->shouldBeCalled(); + $this->logger->log( + 'info', + 'Code refactoring checks completed successfully.', + Argument::that(static fn(array $context): bool => $context['input'] instanceof InputInterface + && $context['output'] instanceof OutputInterface), + )->shouldBeCalled(); self::assertSame(RefactorCommand::SUCCESS, $this->executeCommand()); } diff --git a/tests/Console/Command/ReportsCommandTest.php b/tests/Console/Command/ReportsCommandTest.php index 6f09cbedf3..b652d92af0 100644 --- a/tests/Console/Command/ReportsCommandTest.php +++ b/tests/Console/Command/ReportsCommandTest.php @@ -26,6 +26,10 @@ use FastForward\DevTools\Process\ProcessQueueInterface; use FastForward\DevTools\Path\ManagedWorkspace; use FastForward\DevTools\Path\DevToolsPathResolver; +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Environment\Environment as DevToolsEnvironment; +use FastForward\DevTools\Environment\RuntimeEnvironment; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -35,12 +39,17 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; +use FastForward\DevTools\Tests\Container\UsesContainerFactory; use ReflectionMethod; use Symfony\Component\Console\Formatter\OutputFormatter; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Process\Process; +#[UsesClass(ContainerFactory::class)] +#[UsesClass(DevToolsServiceProvider::class)] +#[UsesClass(DevToolsEnvironment::class)] +#[UsesClass(RuntimeEnvironment::class)] #[CoversClass(ReportsCommand::class)] #[UsesClass(ManagedWorkspace::class)] #[UsesClass(DevToolsPathResolver::class)] @@ -48,6 +57,7 @@ final class ReportsCommandTest extends TestCase { use ProphecyTrait; + use UsesContainerFactory; private ObjectProphecy $processBuilder; @@ -71,6 +81,7 @@ protected function setUp(): void $this->processBuilder = $this->prophesize(ProcessBuilderInterface::class); $this->processQueue = $this->prophesize(ProcessQueueInterface::class); $this->logger = $this->prophesize(LoggerInterface::class); + $this->setContainerEntry(LoggerInterface::class, $this->logger->reveal()); $this->input = $this->prophesize(InputInterface::class); $this->output = $this->prophesize(OutputInterface::class); $this->process = $this->prophesize(Process::class); @@ -107,11 +118,7 @@ protected function setUp(): void ); $this->processBuilder->build(Argument::any())->willReturn($this->process->reveal()); - $this->command = new ReportsCommand( - $this->processBuilder->reveal(), - $this->processQueue->reveal(), - $this->logger->reveal(), - ); + $this->command = new ReportsCommand($this->processBuilder->reveal(), $this->processQueue->reveal()); } /** @@ -124,7 +131,7 @@ public function executeWillRunReportsAndReturnSuccess(): void $this->processQueue->run($this->output->reveal()) ->willReturn(ReportsCommand::SUCCESS) ->shouldBeCalledOnce(); - $this->logger->info('Generating frontpage for Fast Forward documentation...', Argument::that( + $this->logger->log('info', 'Generating frontpage for Fast Forward documentation...', Argument::that( static fn(array $context): bool => $context['input'] instanceof InputInterface )) ->shouldBeCalled(); @@ -152,10 +159,6 @@ public function executeWillCaptureBufferedOutputWhenJsonIsRequested(): void $this->processQueue->run(Argument::type('object')) ->willReturn(ReportsCommand::FAILURE) ->shouldBeCalledOnce(); - $this->logger->info('Generating frontpage for Fast Forward documentation...', Argument::that( - static fn(array $context): bool => $context['input'] instanceof InputInterface - )) - ->shouldBeCalled(); $this->logger->error( 'Documentation reports generation failed.', Argument::that(static fn(array $context): bool => $context['input'] instanceof InputInterface diff --git a/tests/Console/Command/SelfUpdateCommandTest.php b/tests/Console/Command/SelfUpdateCommandTest.php index c3b4baa452..3f8dfbaa94 100644 --- a/tests/Console/Command/SelfUpdateCommandTest.php +++ b/tests/Console/Command/SelfUpdateCommandTest.php @@ -25,6 +25,11 @@ use FastForward\DevTools\Reflection\ClassReflection; use FastForward\DevTools\SelfUpdate\SelfUpdateRunnerInterface; use FastForward\DevTools\SelfUpdate\SelfUpdateScopeResolverInterface; +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Environment\Environment as DevToolsEnvironment; +use FastForward\DevTools\Environment\RuntimeEnvironment; +use FastForward\DevTools\Path\DevToolsPathResolver; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -33,16 +38,23 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; +use FastForward\DevTools\Tests\Container\UsesContainerFactory; use ReflectionMethod; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +#[UsesClass(ContainerFactory::class)] +#[UsesClass(DevToolsPathResolver::class)] +#[UsesClass(DevToolsServiceProvider::class)] +#[UsesClass(DevToolsEnvironment::class)] +#[UsesClass(RuntimeEnvironment::class)] #[CoversClass(SelfUpdateCommand::class)] #[UsesClass(ClassReflection::class)] #[UsesTrait(LogsCommandResults::class)] final class SelfUpdateCommandTest extends TestCase { use ProphecyTrait; + use UsesContainerFactory; /** * @var ObjectProphecy @@ -79,9 +91,15 @@ protected function setUp(): void $this->selfUpdateRunner = $this->prophesize(SelfUpdateRunnerInterface::class); $this->scopeResolver = $this->prophesize(SelfUpdateScopeResolverInterface::class); $this->logger = $this->prophesize(LoggerInterface::class); + $this->setContainerEntry(LoggerInterface::class, $this->logger->reveal()); $this->input = $this->prophesize(InputInterface::class); + + $this->input->getOption('json') + ->willReturn(false); + $this->input->getOption('pretty-json') + ->willReturn(false); $this->output = $this->prophesize(OutputInterface::class); - $this->logger->info(Argument::cetera()) + $this->logger->log('info', Argument::cetera()) ->will(static function (): void {}); $this->logger->log(Argument::cetera()) ->will(static function (): void {}); diff --git a/tests/Console/Command/SkillsCommandTest.php b/tests/Console/Command/SkillsCommandTest.php index d2e72d79c9..2bbcc26020 100644 --- a/tests/Console/Command/SkillsCommandTest.php +++ b/tests/Console/Command/SkillsCommandTest.php @@ -25,6 +25,10 @@ use FastForward\DevTools\Path\DevToolsPathResolver; use FastForward\DevTools\Sync\PackagedDirectorySynchronizer; use FastForward\DevTools\Sync\SynchronizeResult; +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Environment\Environment as DevToolsEnvironment; +use FastForward\DevTools\Environment\RuntimeEnvironment; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -34,12 +38,17 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; +use FastForward\DevTools\Tests\Container\UsesContainerFactory; use ReflectionMethod; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use function Safe\getcwd; +#[UsesClass(ContainerFactory::class)] +#[UsesClass(DevToolsServiceProvider::class)] +#[UsesClass(DevToolsEnvironment::class)] +#[UsesClass(RuntimeEnvironment::class)] #[CoversClass(SkillsCommand::class)] #[UsesClass(DevToolsPathResolver::class)] #[UsesClass(PackagedDirectorySynchronizer::class)] @@ -48,6 +57,7 @@ final class SkillsCommandTest extends TestCase { use ProphecyTrait; + use UsesContainerFactory; private ObjectProphecy $synchronizer; @@ -69,16 +79,18 @@ protected function setUp(): void $this->synchronizer = $this->prophesize(PackagedDirectorySynchronizer::class); $this->filesystem = $this->prophesize(FilesystemInterface::class); $this->logger = $this->prophesize(LoggerInterface::class); + $this->setContainerEntry(LoggerInterface::class, $this->logger->reveal()); $this->input = $this->prophesize(InputInterface::class); + + $this->input->getOption('json') + ->willReturn(false); + $this->input->getOption('pretty-json') + ->willReturn(false); $this->output = $this->prophesize(OutputInterface::class); $this->filesystem->getAbsolutePath('.agents/skills') ->willReturn(getcwd() . '/.agents/skills'); - $this->command = new SkillsCommand( - $this->synchronizer->reveal(), - $this->filesystem->reveal(), - $this->logger->reveal(), - ); + $this->command = new SkillsCommand($this->synchronizer->reveal(), $this->filesystem->reveal()); } /** @@ -92,7 +104,9 @@ public function executeWillFailWhenPackagedSkillsDirectoryDoesNotExist(): void $this->filesystem->exists($skillsPath) ->willReturn(false); $this->synchronizer->synchronize(Argument::cetera())->shouldNotBeCalled(); - $this->logger->info('Starting skills synchronization...') + $this->logger->log('info', 'Starting skills synchronization...', [ + 'input' => $this->input->reveal(), + ]) ->shouldBeCalledOnce(); $this->logger->error( 'No packaged skills found at: {packaged_skills_path}', @@ -125,9 +139,13 @@ public function executeWillCreateSkillsDirectoryWhenItDoesNotExist(): void $this->synchronizer->synchronize($skillsPath, $skillsPath, '.agents/skills') ->willReturn($result) ->shouldBeCalledOnce(); - $this->logger->info('Starting skills synchronization...') + $this->logger->log('info', 'Starting skills synchronization...', [ + 'input' => $this->input->reveal(), + ]) ->shouldBeCalledOnce(); - $this->logger->info('Created .agents/skills directory.') + $this->logger->log('info', 'Created .agents/skills directory.', [ + 'input' => $this->input->reveal(), + ]) ->shouldBeCalledOnce(); $this->logger->log( 'info', @@ -158,7 +176,9 @@ public function executeWillReturnFailureWhenSynchronizerFails(): void $this->synchronizer->synchronize($skillsPath, $skillsPath, '.agents/skills') ->willReturn($result) ->shouldBeCalledOnce(); - $this->logger->info('Starting skills synchronization...') + $this->logger->log('info', 'Starting skills synchronization...', [ + 'input' => $this->input->reveal(), + ]) ->shouldBeCalledOnce(); $this->logger->error( 'Skills synchronization failed.', diff --git a/tests/Console/Command/StandardsCommandTest.php b/tests/Console/Command/StandardsCommandTest.php index 9ebab4d62b..fabfe0f9fa 100644 --- a/tests/Console/Command/StandardsCommandTest.php +++ b/tests/Console/Command/StandardsCommandTest.php @@ -24,6 +24,10 @@ use FastForward\DevTools\Process\ProcessQueueInterface; use FastForward\DevTools\Console\Command\Traits\LogsCommandResults; use FastForward\DevTools\Console\Command\StandardsCommand; +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Environment\Environment as DevToolsEnvironment; +use FastForward\DevTools\Environment\RuntimeEnvironment; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -33,12 +37,17 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; +use FastForward\DevTools\Tests\Container\UsesContainerFactory; use ReflectionMethod; use FastForward\DevTools\Path\ManagedWorkspace; use FastForward\DevTools\Path\DevToolsPathResolver; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +#[UsesClass(ContainerFactory::class)] +#[UsesClass(DevToolsServiceProvider::class)] +#[UsesClass(DevToolsEnvironment::class)] +#[UsesClass(RuntimeEnvironment::class)] #[CoversClass(StandardsCommand::class)] #[UsesClass(ManagedWorkspace::class)] #[UsesClass(DevToolsPathResolver::class)] @@ -46,6 +55,7 @@ final class StandardsCommandTest extends TestCase { use ProphecyTrait; + use UsesContainerFactory; private ObjectProphecy $processBuilder; @@ -67,6 +77,7 @@ protected function setUp(): void $this->processBuilder = $this->prophesize(ProcessBuilderInterface::class); $this->processQueue = $this->prophesize(ProcessQueueInterface::class); $this->logger = $this->prophesize(LoggerInterface::class); + $this->setContainerEntry(LoggerInterface::class, $this->logger->reveal()); $this->input = $this->prophesize(InputInterface::class); $this->output = $this->prophesize(OutputInterface::class); @@ -91,11 +102,7 @@ protected function setUp(): void $this->processBuilder->build(Argument::any()) ->willReturn($this->prophesize(Process::class)->reveal()); - $this->command = new StandardsCommand( - $this->processBuilder->reveal(), - $this->processQueue->reveal(), - $this->logger->reveal(), - ); + $this->command = new StandardsCommand($this->processBuilder->reveal(), $this->processQueue->reveal()); } /** @@ -109,7 +116,7 @@ public function executeWillRunSuiteSequentially(): void $this->processQueue->run($this->output->reveal()) ->willReturn(StandardsCommand::SUCCESS) ->shouldBeCalledOnce(); - $this->logger->info('Running code standards checks...', Argument::that( + $this->logger->log('info', 'Running code standards checks...', Argument::that( static fn(array $context): bool => $context['input'] instanceof InputInterface )) ->shouldBeCalled(); @@ -135,7 +142,7 @@ public function executeWillReturnFailureWhenAnyCommandFails(): void $this->processQueue->run($this->output->reveal()) ->willReturn(StandardsCommand::FAILURE) ->shouldBeCalledOnce(); - $this->logger->info('Running code standards checks...', Argument::that( + $this->logger->log('info', 'Running code standards checks...', Argument::that( static fn(array $context): bool => $context['input'] instanceof InputInterface )) ->shouldBeCalled(); @@ -177,6 +184,35 @@ public function executeWithCacheWillForwardCacheOnlyToCacheAwareNestedCommands() self::assertSame(StandardsCommand::SUCCESS, $this->invokeExecute()); } + /** + * @return void + */ + #[Test] + public function executeWillSuppressProgressLogWhenJsonIsRequested(): void + { + $this->input->getOption('json') + ->willReturn(true); + $this->input->getOption('pretty-json') + ->willReturn(false); + $this->processBuilder->withArgument('--json') + ->willReturn($this->processBuilder->reveal()) + ->shouldBeCalledTimes(4); + $this->processQueue->add(Argument::type(Process::class), Argument::cetera()) + ->shouldBeCalledTimes(4); + $this->processQueue->run(Argument::type(OutputInterface::class)) + ->willReturn(StandardsCommand::SUCCESS) + ->shouldBeCalledOnce(); + $this->logger->log( + 'info', + 'Code standards checks completed successfully.', + Argument::that(static fn(array $context): bool => $context['input'] instanceof InputInterface + && $context['output'] instanceof OutputInterface + && ['refactor', 'phpdoc', 'code-style', 'reports'] === $context['commands']), + )->shouldBeCalled(); + + self::assertSame(StandardsCommand::SUCCESS, $this->invokeExecute()); + } + /** * @return void */ diff --git a/tests/Console/Command/SyncCommandTest.php b/tests/Console/Command/SyncCommandTest.php index a7f695b537..096248b947 100644 --- a/tests/Console/Command/SyncCommandTest.php +++ b/tests/Console/Command/SyncCommandTest.php @@ -24,6 +24,10 @@ use FastForward\DevTools\Path\DevToolsPathResolver; use FastForward\DevTools\Process\ProcessBuilder; use FastForward\DevTools\Process\ProcessQueueInterface; +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Environment\Environment as DevToolsEnvironment; +use FastForward\DevTools\Environment\RuntimeEnvironment; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -33,11 +37,16 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; +use FastForward\DevTools\Tests\Container\UsesContainerFactory; use ReflectionMethod; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Process\Process; +#[UsesClass(ContainerFactory::class)] +#[UsesClass(DevToolsServiceProvider::class)] +#[UsesClass(DevToolsEnvironment::class)] +#[UsesClass(RuntimeEnvironment::class)] #[CoversClass(SyncCommand::class)] #[UsesClass(DevToolsPathResolver::class)] #[UsesClass(ProcessBuilder::class)] @@ -45,6 +54,7 @@ final class SyncCommandTest extends TestCase { use ProphecyTrait; + use UsesContainerFactory; private ObjectProphecy $processQueue; @@ -65,6 +75,7 @@ protected function setUp(): void $this->input = $this->prophesize(InputInterface::class); $this->output = $this->prophesize(OutputInterface::class); $this->logger = $this->prophesize(LoggerInterface::class); + $this->setContainerEntry(LoggerInterface::class, $this->logger->reveal()); $this->input->getOption(Argument::type('string'))->willReturn(false); $this->input->getOption('json') @@ -72,11 +83,7 @@ protected function setUp(): void $this->input->getOption('pretty-json') ->willReturn(false); - $this->command = new SyncCommand( - new ProcessBuilder(), - $this->processQueue->reveal(), - $this->logger->reveal(), - ); + $this->command = new SyncCommand(new ProcessBuilder(), $this->processQueue->reveal()); } /** @@ -94,7 +101,7 @@ public function executeWillQueueDedicatedSynchronizationCommands(): void $this->processQueue->run($this->output->reveal()) ->willReturn(SyncCommand::SUCCESS) ->shouldBeCalledOnce(); - $this->logger->info('Starting dev-tools synchronization...', Argument::that( + $this->logger->log('info', 'Starting dev-tools synchronization...', Argument::that( static fn(array $context): bool => $context['input'] instanceof InputInterface )) ->shouldBeCalled(); @@ -124,11 +131,12 @@ public function executeWillDisableDetachedModeWhenCheckingDrift(): void $this->processQueue->run($this->output->reveal()) ->willReturn(SyncCommand::FAILURE) ->shouldBeCalledOnce(); - $this->logger->info('Starting dev-tools synchronization...', Argument::that( + $this->logger->log('info', 'Starting dev-tools synchronization...', Argument::that( static fn(array $context): bool => $context['input'] instanceof InputInterface )) ->shouldBeCalled(); - $this->logger->warning( + $this->logger->log( + 'warning', 'Skipping wiki, skills, and agents during preview/check modes because they do not yet expose non-destructive verification.', Argument::that(static fn(array $context): bool => $context['input'] instanceof InputInterface), )->shouldBeCalled(); @@ -164,10 +172,6 @@ public function executeWillPropagateJsonOutputFormatToSubCommands(): void $this->processQueue->run(Argument::type('object')) ->willReturn(SyncCommand::SUCCESS) ->shouldBeCalledOnce(); - $this->logger->info('Starting dev-tools synchronization...', Argument::that( - static fn(array $context): bool => $context['input'] instanceof InputInterface - )) - ->shouldBeCalled(); $this->logger->log( 'info', 'Dev-tools synchronization completed successfully.', diff --git a/tests/Console/Command/TestsCommandTest.php b/tests/Console/Command/TestsCommandTest.php index 9e163c6a62..758543a4bb 100644 --- a/tests/Console/Command/TestsCommandTest.php +++ b/tests/Console/Command/TestsCommandTest.php @@ -22,6 +22,9 @@ use FastForward\DevTools\Composer\Json\ComposerJsonInterface; use FastForward\DevTools\Console\Command\Traits\LogsCommandResults; use FastForward\DevTools\Console\Command\TestsCommand; +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Environment\RuntimeEnvironmentInterface; use FastForward\DevTools\Filesystem\FilesystemInterface; use FastForward\DevTools\PhpUnit\Bootstrap\BootstrapShimGenerator; use FastForward\DevTools\PhpUnit\Coverage\CoverageSummary; @@ -33,6 +36,8 @@ use FastForward\DevTools\Project\ProjectCapabilities; use FastForward\DevTools\Project\ProjectCapabilitiesResolverInterface; use FastForward\DevTools\Path\WorkingProjectPathResolver; +use FastForward\DevTools\Environment\Environment as DevToolsEnvironment; +use FastForward\DevTools\Environment\RuntimeEnvironment; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -50,10 +55,15 @@ use Symfony\Component\Process\Process; use function Safe\getcwd; +use function Safe\putenv; +#[UsesClass(DevToolsEnvironment::class)] +#[UsesClass(RuntimeEnvironment::class)] #[CoversClass(TestsCommand::class)] #[UsesClass(BootstrapShimGenerator::class)] #[UsesClass(CoverageSummary::class)] +#[UsesClass(ContainerFactory::class)] +#[UsesClass(DevToolsServiceProvider::class)] #[UsesClass(DevToolsPathResolver::class)] #[UsesClass(ProcessBuilder::class)] #[UsesClass(ManagedWorkspace::class)] @@ -76,6 +86,8 @@ final class TestsCommandTest extends TestCase private ObjectProphecy $projectCapabilitiesResolver; + private ObjectProphecy $runtimeEnvironment; + private ObjectProphecy $logger; private ObjectProphecy $input; @@ -84,17 +96,22 @@ final class TestsCommandTest extends TestCase private TestsCommand $command; + private string|false $agentEnvironment; + /** * @return void */ protected function setUp(): void { + ContainerFactory::reset(); + $this->agentEnvironment = getenv('AI_AGENT'); $this->coverageSummaryLoader = $this->prophesize(CoverageSummaryLoaderInterface::class); $this->composerJson = $this->prophesize(ComposerJsonInterface::class); $this->filesystem = $this->prophesize(FilesystemInterface::class); $this->fileLocator = $this->prophesize(FileLocatorInterface::class); $this->processQueue = $this->prophesize(ProcessQueueInterface::class); $this->projectCapabilitiesResolver = $this->prophesize(ProjectCapabilitiesResolverInterface::class); + $this->runtimeEnvironment = $this->prophesize(RuntimeEnvironmentInterface::class); $this->logger = $this->prophesize(LoggerInterface::class); $this->input = $this->prophesize(InputInterface::class); $this->output = $this->prophesize(OutputInterface::class); @@ -108,7 +125,6 @@ protected function setUp(): void new ProcessBuilder(), $this->processQueue->reveal(), $this->projectCapabilitiesResolver->reveal(), - $this->logger->reveal(), ); $this->composerJson->getAutoload('psr-4') @@ -124,6 +140,12 @@ protected function setUp(): void false, true, )); + $this->runtimeEnvironment->isAgentPresent() + ->willReturn(false); + $this->runtimeEnvironment->isComposerTestRun() + ->willReturn(true); + ContainerFactory::set(RuntimeEnvironmentInterface::class, $this->runtimeEnvironment->reveal()); + ContainerFactory::set(LoggerInterface::class, $this->logger->reveal()); $this->fileLocator->locate(TestsCommand::CONFIG)->willReturn(getcwd() . '/' . TestsCommand::CONFIG); $this->filesystem->getAbsolutePath('./vendor/autoload.php') ->willReturn(getcwd() . '/vendor/autoload.php'); @@ -150,6 +172,22 @@ protected function setUp(): void ->willReturn(false); } + /** + * @return void + */ + protected function tearDown(): void + { + ContainerFactory::reset(); + + if (false === $this->agentEnvironment) { + putenv('AI_AGENT'); + + return; + } + + putenv('AI_AGENT=' . $this->agentEnvironment); + } + /** * @return void */ @@ -178,7 +216,7 @@ public function executeWillRunPhpUnitProcessWithConfigFile(): void )->shouldBeCalled(); $this->processQueue->run($this->output->reveal()) ->willReturn(TestsCommand::SUCCESS)->shouldBeCalled(); - $this->logger->info('Running PHPUnit tests...', Argument::that( + $this->logger->log('info', 'Running PHPUnit tests...', Argument::that( static fn(array $context): bool => $context['input'] instanceof InputInterface )) ->shouldBeCalled(); @@ -228,25 +266,253 @@ public function executeWillDisablePhpUnitProgressWhenJsonIsRequested(): void ->willReturn(false); $this->processQueue->add( - Argument::that(static fn(Process $process): bool => str_contains( - $process->getCommandLine(), - '--no-progress', - ) && ! str_contains($process->getCommandLine(), '--colors=always')), + Argument::that(fn(Process $process): bool => $this->usesStructuredPhpUnitExecution($process)), + false, + false, + 'Running PHPUnit Tests' + )->shouldBeCalled(); + $this->processQueue->run(Argument::type(OutputInterface::class)) + ->will(static function (array $arguments): int { + $arguments[0]->write( + "{\n \"result\": \"success\",\n \"summary\": {\n \"assertions\": 5,\n \"failures\": 0,\n \"tests\": 2,\n \"warnings\": 0\n }\n}\n" + ); + + return TestsCommand::SUCCESS; + })->shouldBeCalled(); + $this->logger->log('info', Argument::cetera())->shouldBeCalled(); + $this->logger->log( + 'info', + 'PHPUnit tests completed successfully.', + Argument::that(static fn(array $context): bool => $context['input'] instanceof InputInterface + && isset($context['output']) + && 'success' === $context['output']['result'] + && 5 === $context['output']['summary']['assertions']), + )->shouldBeCalled(); + $this->output->writeln(Argument::cetera())->shouldNotBeCalled(); + + self::assertSame(TestsCommand::SUCCESS, $this->invokeExecute()); + } + + /** + * @return void + */ + #[Test] + public function executeWillCaptureStructuredPhpUnitSummaryWhenAgentEnvironmentIsDetected(): void + { + $this->runtimeEnvironment->isAgentPresent() + ->willReturn(true); + $this->runtimeEnvironment->isComposerTestRun() + ->willReturn(false); + + $this->processQueue->add( + Argument::that(fn(Process $process): bool => $this->usesStructuredPhpUnitExecution($process)), + false, + false, + 'Running PHPUnit Tests' + )->shouldBeCalled(); + $this->processQueue->run(Argument::type(OutputInterface::class)) + ->will(static function (array $arguments): int { + $arguments[0]->write( + "{\n \"result\": \"success\",\n \"summary\": {\n \"assertions\": 5,\n \"failures\": 0,\n \"tests\": 2,\n \"warnings\": 0\n }\n}\n" + ); + + return TestsCommand::SUCCESS; + })->shouldBeCalled(); + $this->logger->log('info', Argument::cetera())->shouldBeCalled(); + $this->logger->log( + 'info', + 'PHPUnit tests completed successfully.', + Argument::that(static fn(array $context): bool => $context['input'] instanceof InputInterface + && isset($context['output']) + && 'success' === $context['output']['result'] + && 5 === $context['output']['summary']['assertions']), + )->shouldBeCalled(); + $this->output->writeln(Argument::cetera())->shouldNotBeCalled(); + + self::assertSame(TestsCommand::SUCCESS, $this->invokeExecute()); + } + + /** + * @return void + */ + #[Test] + public function executeWillPreserveAnInheritedAgentEnvironmentWhenForcingStructuredPhpUnitOutput(): void + { + putenv('AI_AGENT=existing-agent'); + + $this->input->getOption('json') + ->willReturn(true); + $this->input->getOption('pretty-json') + ->willReturn(false); + + $this->processQueue->add( + Argument::that(static fn(Process $process): bool => ! \array_key_exists('AI_AGENT', $process->getEnv())), false, false, 'Running PHPUnit Tests' )->shouldBeCalled(); $this->processQueue->run(Argument::type(OutputInterface::class)) ->willReturn(TestsCommand::SUCCESS)->shouldBeCalled(); - $this->logger->info('Running PHPUnit tests...', Argument::that( - static fn(array $context): bool => $context['input'] instanceof InputInterface - )) - ->shouldBeCalled(); + $this->logger->log('info', Argument::cetera())->shouldBeCalled(); + $this->logger->log( + 'info', + 'PHPUnit tests completed successfully.', + Argument::type('array'), + )->shouldBeCalled(); + + self::assertSame(TestsCommand::SUCCESS, $this->invokeExecute()); + } + + /** + * @return void + */ + #[Test] + public function executeWillKeepPrettyJsonInsideTheStandardCommandLogOutput(): void + { + $this->input->getOption('json') + ->willReturn(false); + $this->input->getOption('pretty-json') + ->willReturn(true); + + $this->processQueue->add( + Argument::that(fn(Process $process): bool => $this->usesStructuredPhpUnitExecution($process)), + false, + false, + 'Running PHPUnit Tests' + )->shouldBeCalled(); + $this->processQueue->run(Argument::type(OutputInterface::class)) + ->will(static function (array $arguments): int { + $arguments[0]->write( + "{\n \"result\": \"success\",\n \"summary\": {\n \"assertions\": 5,\n \"failures\": 0,\n \"tests\": 2,\n \"warnings\": 0\n }\n}\n" + ); + + return TestsCommand::SUCCESS; + })->shouldBeCalled(); + $this->logger->log('info', Argument::cetera())->shouldBeCalled(); $this->logger->log( 'info', 'PHPUnit tests completed successfully.', - Argument::that(static fn(array $context): bool => $context['input'] instanceof InputInterface), + Argument::that(static fn(array $context): bool => $context['input'] instanceof InputInterface + && isset($context['output']) + && 'success' === $context['output']['result'] + && 5 === $context['output']['summary']['assertions']), )->shouldBeCalled(); + $this->output->writeln(Argument::cetera())->shouldNotBeCalled(); + + self::assertSame(TestsCommand::SUCCESS, $this->invokeExecute()); + } + + /** + * @return void + */ + #[Test] + public function executeWillCaptureStructuredPhpUnitSummaryAfterCoveragePreludeWhenAgentEnvironmentIsDetected(): void + { + $this->runtimeEnvironment->isAgentPresent() + ->willReturn(true); + $this->runtimeEnvironment->isComposerTestRun() + ->willReturn(false); + + $this->processQueue->add( + Argument::type(Process::class), + false, + false, + 'Running PHPUnit Tests' + )->shouldBeCalled(); + $this->processQueue->run(Argument::type(OutputInterface::class)) + ->will(static function (array $arguments): int { + $arguments[0]->write( + "Generating code coverage report in PHP format ... done [00:00.002]\n\n{\n \"result\": \"success\",\n \"summary\": {\n \"assertions\": 5,\n \"failures\": 0,\n \"tests\": 2,\n \"warnings\": 0\n }\n}\n" + ); + + return TestsCommand::SUCCESS; + })->shouldBeCalled(); + $this->logger->log('info', Argument::cetera())->shouldBeCalled(); + $this->logger->log( + 'info', + 'PHPUnit tests completed successfully.', + Argument::that(static fn(array $context): bool => $context['input'] instanceof InputInterface + && isset($context['output']) + && 'success' === $context['output']['result'] + && 5 === $context['output']['summary']['assertions'] + && 'Generating code coverage report in PHP format ... done [00:00.002]' === $context['output']['raw_output']), + )->shouldBeCalled(); + $this->output->writeln(Argument::cetera())->shouldNotBeCalled(); + + self::assertSame(TestsCommand::SUCCESS, $this->invokeExecute()); + } + + /** + * @return void + */ + #[Test] + public function executeWillKeepTheExitCodeDerivedFailureResultAuthoritative(): void + { + $this->input->getOption('json') + ->willReturn(true); + $this->input->getOption('pretty-json') + ->willReturn(false); + + $this->processQueue->add( + Argument::type(Process::class), + false, + false, + 'Running PHPUnit Tests' + )->shouldBeCalled(); + $this->processQueue->run(Argument::type(OutputInterface::class)) + ->will(static function (array $arguments): int { + $arguments[0]->write( + "{\n \"result\": \"success\",\n \"summary\": {\n \"assertions\": 5,\n \"failures\": 1,\n \"tests\": 2,\n \"warnings\": 0\n }\n}\n" + ); + + return TestsCommand::FAILURE; + })->shouldBeCalled(); + $this->logger->log(Argument::cetera())->shouldNotBeCalled(); + $this->logger->error( + 'PHPUnit tests failed.', + Argument::that(static fn(array $context): bool => $context['input'] instanceof InputInterface + && isset($context['output']) + && 'failure' === $context['output']['result'] + && 1 === $context['output']['summary']['failures']), + )->shouldBeCalled(); + $this->output->writeln(Argument::cetera())->shouldNotBeCalled(); + + self::assertSame(TestsCommand::FAILURE, $this->invokeExecute()); + } + + /** + * @return void + */ + #[Test] + public function executeWillKeepRawPhpUnitOutputWhenStructuredSummaryCannotBeDecoded(): void + { + $this->input->getOption('json') + ->willReturn(true); + $this->input->getOption('pretty-json') + ->willReturn(false); + + $this->processQueue->add( + Argument::type(Process::class), + false, + false, + 'Running PHPUnit Tests' + )->shouldBeCalled(); + $this->processQueue->run(Argument::type(OutputInterface::class)) + ->will(static function (array $arguments): int { + $arguments[0]->write("PHPUnit 12.5.24 by Sebastian Bergmann.\n\nOK (2 tests, 5 assertions)\n"); + + return TestsCommand::SUCCESS; + })->shouldBeCalled(); + $this->logger->log('info', Argument::cetera())->shouldBeCalled(); + $this->logger->log( + 'info', + 'PHPUnit tests completed successfully.', + Argument::that(static fn(array $context): bool => $context['input'] instanceof InputInterface + && isset($context['output']) + && 'success' === $context['output']['result'] + && "PHPUnit 12.5.24 by Sebastian Bergmann.\n\nOK (2 tests, 5 assertions)" === $context['output']['raw_output']), + )->shouldBeCalled(); + $this->output->writeln(Argument::cetera())->shouldNotBeCalled(); self::assertSame(TestsCommand::SUCCESS, $this->invokeExecute()); } @@ -284,7 +550,7 @@ public function executeWithInvalidMinCoverageWillReturnFailure(): void $this->input->getOption('min-coverage') ->willReturn('invalid'); $this->processQueue->run(Argument::cetera())->shouldNotBeCalled(); - $this->logger->info('Running PHPUnit tests...', Argument::that( + $this->logger->log('info', 'Running PHPUnit tests...', Argument::that( static fn(array $context): bool => $context['input'] instanceof InputInterface )) ->shouldBeCalled(); @@ -307,7 +573,7 @@ public function executeWillSkipWhenNoTestsDirectoryOrPhpSourceExists(): void ->willReturn(new ProjectCapabilities([], null, false, false, false, false)); $this->processQueue->add(Argument::cetera())->shouldNotBeCalled(); $this->processQueue->run(Argument::cetera())->shouldNotBeCalled(); - $this->logger->info('Running PHPUnit tests...', Argument::that( + $this->logger->log('info', 'Running PHPUnit tests...', Argument::that( static fn(array $context): bool => $context['input'] instanceof InputInterface ))->shouldBeCalled(); $this->logger->log( @@ -336,7 +602,7 @@ public function executeWillFailWhenCustomTestsPathDoesNotExist(): void ->shouldNotBeCalled(); $this->processQueue->run(Argument::cetera()) ->shouldNotBeCalled(); - $this->logger->info('Running PHPUnit tests...', Argument::that( + $this->logger->log('info', 'Running PHPUnit tests...', Argument::that( static fn(array $context): bool => $context['input'] instanceof InputInterface ))->shouldBeCalled(); $this->logger->error( @@ -369,7 +635,7 @@ public function executeWithCoverageBelowMinimumWillReturnFailure(): void )->shouldBeCalled(); $this->processQueue->run($this->output->reveal()) ->willReturn(TestsCommand::SUCCESS)->shouldBeCalled(); - $this->logger->info('Running PHPUnit tests...', Argument::that( + $this->logger->log('info', 'Running PHPUnit tests...', Argument::that( static fn(array $context): bool => $context['input'] instanceof InputInterface )) ->shouldBeCalled(); @@ -385,6 +651,51 @@ public function executeWithCoverageBelowMinimumWillReturnFailure(): void self::assertSame(TestsCommand::FAILURE, $this->invokeExecute()); } + /** + * @return void + */ + #[Test] + public function executeWillEmitStructuredCoverageFailurePayloadWhenMinimumCoverageIsNotMet(): void + { + $coverageReportPath = getcwd() . '/.dev-tools/cache/phpunit/coverage.php'; + + $this->runtimeEnvironment->isAgentPresent() + ->willReturn(true); + $this->runtimeEnvironment->isComposerTestRun() + ->willReturn(false); + $this->input->getOption('min-coverage') + ->willReturn('80'); + $this->coverageSummaryLoader->load($coverageReportPath) + ->willReturn(new CoverageSummary(75, 100)); + $this->processQueue->add( + Argument::type(Process::class), + false, + false, + 'Running PHPUnit Tests' + )->shouldBeCalled(); + $this->processQueue->run(Argument::type(OutputInterface::class)) + ->will(static function (array $arguments): int { + $arguments[0]->write( + "{\n \"result\": \"success\",\n \"summary\": {\n \"assertions\": 5,\n \"failures\": 0,\n \"tests\": 2,\n \"warnings\": 0\n }\n}\n" + ); + + return TestsCommand::SUCCESS; + })->shouldBeCalled(); + $this->logger->log(Argument::cetera())->shouldNotBeCalled(); + $this->logger->error( + 'Minimum line coverage of 80.00% was not met. Current coverage: 75.00% (75/100 lines).', + Argument::that(static fn(array $context): bool => $context['input'] instanceof InputInterface + && isset($context['output']) + && 'failure' === $context['output']['result'] + && 80.0 === (float) $context['output']['coverage']['minimum'] + && 75.0 === (float) $context['output']['coverage']['line_coverage'] + && 'Minimum line coverage of 80.00% was not met. Current coverage: 75.00% (75/100 lines).' === $context['output']['message']), + )->shouldBeCalled(); + $this->output->writeln(Argument::cetera())->shouldNotBeCalled(); + + self::assertSame(TestsCommand::FAILURE, $this->invokeExecute()); + } + /** * @return void */ @@ -405,7 +716,7 @@ public function executeWillReturnFailureWhenCoverageSummaryCannotBeLoaded(): voi )->shouldBeCalled(); $this->processQueue->run($this->output->reveal()) ->willReturn(TestsCommand::SUCCESS)->shouldBeCalled(); - $this->logger->info('Running PHPUnit tests...', Argument::that( + $this->logger->log('info', 'Running PHPUnit tests...', Argument::that( static fn(array $context): bool => $context['input'] instanceof InputInterface )) ->shouldBeCalled(); @@ -429,4 +740,28 @@ private function invokeExecute(): int return (new ReflectionMethod($this->command, 'execute')) ->invoke($this->command, $this->input->reveal(), $this->output->reveal()); } + + /** + * @param Process $process + * + * @return bool + */ + private function usesStructuredPhpUnitExecution(Process $process): bool + { + if (! str_contains($process->getCommandLine(), '--no-progress')) { + return false; + } + + if (str_contains($process->getCommandLine(), '--colors=always')) { + return false; + } + + $processEnvironment = $process->getEnv(); + + if (\array_key_exists('AI_AGENT', $processEnvironment)) { + return 'fast-forward/dev-tools' === $processEnvironment['AI_AGENT']; + } + + return false !== getenv('AI_AGENT'); + } } diff --git a/tests/Console/Command/UpdateComposerJsonCommandTest.php b/tests/Console/Command/UpdateComposerJsonCommandTest.php index d53cfc21de..66455a5288 100644 --- a/tests/Console/Command/UpdateComposerJsonCommandTest.php +++ b/tests/Console/Command/UpdateComposerJsonCommandTest.php @@ -26,11 +26,16 @@ use FastForward\DevTools\Path\DevToolsPathResolver; use FastForward\DevTools\Resource\FileDiff; use FastForward\DevTools\Resource\FileDiffer; +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Environment\Environment as DevToolsEnvironment; +use FastForward\DevTools\Environment\RuntimeEnvironment; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; +use FastForward\DevTools\Tests\Container\UsesContainerFactory; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; @@ -43,6 +48,10 @@ use function Safe\json_decode; +#[UsesClass(ContainerFactory::class)] +#[UsesClass(DevToolsServiceProvider::class)] +#[UsesClass(DevToolsEnvironment::class)] +#[UsesClass(RuntimeEnvironment::class)] #[CoversClass(UpdateComposerJsonCommand::class)] #[UsesClass(DevToolsPathResolver::class)] #[UsesClass(FileDiff::class)] @@ -50,6 +59,7 @@ final class UpdateComposerJsonCommandTest extends TestCase { use ProphecyTrait; + use UsesContainerFactory; private ObjectProphecy $filesystem; @@ -78,16 +88,22 @@ protected function setUp(): void $this->filesystem = $this->prophesize(FilesystemInterface::class); $this->fileLocator = $this->prophesize(FileLocatorInterface::class); $this->input = $this->prophesize(InputInterface::class); + + $this->input->getOption('json') + ->willReturn(false); + $this->input->getOption('pretty-json') + ->willReturn(false); $this->output = $this->prophesize(OutputInterface::class); $this->fileDiffer = $this->prophesize(FileDiffer::class); $this->logger = $this->prophesize(LoggerInterface::class); + $this->setContainerEntry(LoggerInterface::class, $this->logger->reveal()); $this->io = $this->prophesize(SymfonyStyle::class); $this->output->isDecorated() ->willReturn(false); $this->fileDiffer->formatForConsole(Argument::cetera()) ->willReturn(null); - $this->logger->info(Argument::cetera())->will(static function (): void {}); - $this->logger->notice(Argument::cetera())->will(static function (): void {}); + $this->logger->log('info', Argument::cetera())->will(static function (): void {}); + $this->logger->log('notice', Argument::cetera())->will(static function (): void {}); $this->logger->log(Argument::cetera())->will(static function (): void {}); $this->logger->error(Argument::cetera())->will(static function (): void {}); $this->input->getOption('dry-run') @@ -107,7 +123,6 @@ protected function setUp(): void $this->fileLocator->reveal(), new ManagedConfigPathSynchronizer(), $this->fileDiffer->reveal(), - $this->logger->reveal(), $this->io->reveal(), ); } @@ -442,7 +457,8 @@ public function executeWillReturnFailureInCheckModeWhenComposerJsonWouldChange() $this->fileDiffer->formatForConsole('@@ diff @@', false) ->willReturn('@@ diff @@') ->shouldBeCalledOnce(); - $this->logger->notice( + $this->logger->log( + 'notice', '@@ diff @@', [ 'input' => $this->input->reveal(), diff --git a/tests/Console/Command/WikiCommandTest.php b/tests/Console/Command/WikiCommandTest.php index 69c287cfb9..8e7d354f24 100644 --- a/tests/Console/Command/WikiCommandTest.php +++ b/tests/Console/Command/WikiCommandTest.php @@ -31,6 +31,10 @@ use FastForward\DevTools\Project\ProjectCapabilities; use FastForward\DevTools\Project\ProjectCapabilitiesResolverInterface; use FastForward\DevTools\Path\WorkingProjectPathResolver; +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Environment\Environment as DevToolsEnvironment; +use FastForward\DevTools\Environment\RuntimeEnvironment; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\TestWith; @@ -41,6 +45,7 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; +use FastForward\DevTools\Tests\Container\UsesContainerFactory; use ReflectionMethod; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -48,6 +53,10 @@ use function Safe\getcwd; +#[UsesClass(ContainerFactory::class)] +#[UsesClass(DevToolsServiceProvider::class)] +#[UsesClass(DevToolsEnvironment::class)] +#[UsesClass(RuntimeEnvironment::class)] #[CoversClass(WikiCommand::class)] #[UsesClass(DevToolsPathResolver::class)] #[UsesClass(ManagedWorkspace::class)] @@ -57,6 +66,7 @@ final class WikiCommandTest extends TestCase { use ProphecyTrait; + use UsesContainerFactory; private ObjectProphecy $processBuilder; @@ -92,6 +102,7 @@ protected function setUp(): void $this->gitClient = $this->prophesize(GitClientInterface::class); $this->projectCapabilitiesResolver = $this->prophesize(ProjectCapabilitiesResolverInterface::class); $this->logger = $this->prophesize(LoggerInterface::class); + $this->setContainerEntry(LoggerInterface::class, $this->logger->reveal()); $this->input = $this->prophesize(InputInterface::class); $this->output = $this->prophesize(OutputInterface::class); $this->process = $this->prophesize(Process::class); @@ -132,7 +143,6 @@ protected function setUp(): void $this->filesystem->reveal(), $this->gitClient->reveal(), $this->projectCapabilitiesResolver->reveal(), - $this->logger->reveal(), ); } @@ -165,7 +175,7 @@ public function executeWillReturnSuccessWhenProcessQueueSucceeds(): void $this->processQueue->run($this->output->reveal()) ->willReturn(WikiCommand::SUCCESS) ->shouldBeCalled(); - $this->logger->info('Generating wiki documentation...', Argument::that( + $this->logger->log('info', 'Generating wiki documentation...', Argument::that( static fn(array $context): bool => $context['input'] instanceof InputInterface )) ->shouldBeCalled(); @@ -179,6 +189,49 @@ public function executeWillReturnSuccessWhenProcessQueueSucceeds(): void self::assertSame(WikiCommand::SUCCESS, $this->executeCommand()); } + /** + * @return void + */ + #[Test] + public function executeWillSuppressProgressLogWhenJsonIsRequested(): void + { + $this->input->getOption('json') + ->willReturn(true); + $this->input->getOption('pretty-json') + ->willReturn(false); + $this->processBuilder->withArgument( + '--template', + DevToolsPathResolver::getPreferredVendorPath('vendor/saggre/phpdocumentor-markdown/themes/markdown') + ) + ->willReturn($this->processBuilder->reveal()) + ->shouldBeCalled(); + $this->processBuilder->withArgument( + '--cache-folder', + ManagedWorkspace::getCacheDirectory(ManagedWorkspace::PHPDOC) + ) + ->willReturn($this->processBuilder->reveal()) + ->shouldBeCalled(); + $this->filesystem->getAbsolutePath('src/') + ->willReturn(getcwd() . '/src') + ->shouldBeCalled(); + $this->processBuilder->withArgument('--directory', getcwd() . '/src') + ->willReturn($this->processBuilder->reveal()) + ->shouldBeCalled(); + $this->processQueue->add($this->process->reveal(), Argument::cetera()) + ->shouldBeCalled(); + $this->processQueue->run(Argument::type(OutputInterface::class)) + ->willReturn(WikiCommand::SUCCESS) + ->shouldBeCalled(); + $this->logger->log( + 'info', + 'Wiki documentation generated successfully.', + Argument::that(static fn(array $context): bool => $context['input'] instanceof InputInterface + && $context['output'] instanceof OutputInterface), + )->shouldBeCalled(); + + self::assertSame(WikiCommand::SUCCESS, $this->executeCommand()); + } + /** * @return void */ @@ -214,7 +267,7 @@ public function executeWillSkipWhenWikiTargetDoesNotExist(): void ->willReturn(new ProjectCapabilities([], null, false, false, false, false)); $this->processQueue->add(Argument::cetera()) ->shouldNotBeCalled(); - $this->logger->info('Generating wiki documentation...', Argument::that( + $this->logger->log('info', 'Generating wiki documentation...', Argument::that( static fn(array $context): bool => $context['input'] instanceof InputInterface ))->shouldBeCalled(); $this->logger->log( @@ -241,7 +294,7 @@ public function executeWillTreatEquivalentDefaultWikiTargetsAsDefault(string $ta ->willReturn(new ProjectCapabilities([], null, false, false, false, false)); $this->processQueue->add(Argument::cetera()) ->shouldNotBeCalled(); - $this->logger->info('Generating wiki documentation...', Argument::that( + $this->logger->log('info', 'Generating wiki documentation...', Argument::that( static fn(array $context): bool => $context['input'] instanceof InputInterface ))->shouldBeCalled(); $this->logger->log( @@ -278,7 +331,7 @@ public function executeWillGenerateWhenCustomWikiTargetDoesNotExist(): void $this->processQueue->run($this->output->reveal()) ->willReturn(WikiCommand::SUCCESS) ->shouldBeCalled(); - $this->logger->info('Generating wiki documentation...', Argument::that( + $this->logger->log('info', 'Generating wiki documentation...', Argument::that( static fn(array $context): bool => $context['input'] instanceof InputInterface ))->shouldBeCalled(); $this->logger->log( @@ -306,7 +359,7 @@ public function executeWillSkipWhenNoApiDirectoriesAreDetected(): void ->willReturn(new ProjectCapabilities([], null, false, false, true, false)); $this->processQueue->add(Argument::cetera()) ->shouldNotBeCalled(); - $this->logger->info('Generating wiki documentation...', Argument::that( + $this->logger->log('info', 'Generating wiki documentation...', Argument::that( static fn(array $context): bool => $context['input'] instanceof InputInterface ))->shouldBeCalled(); $this->logger->log( diff --git a/tests/Console/DevToolsTest.php b/tests/Console/DevToolsTest.php index 240276a54b..d8511670ab 100644 --- a/tests/Console/DevToolsTest.php +++ b/tests/Console/DevToolsTest.php @@ -24,6 +24,8 @@ use FastForward\DevTools\Console\DevTools; use FastForward\DevTools\Console\Formatter\LogLevelOutputFormatter; use FastForward\DevTools\Console\Output\GithubActionOutput; +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; use FastForward\DevTools\Environment\EnvironmentInterface; use FastForward\DevTools\Environment\RuntimeEnvironment; use FastForward\DevTools\Environment\RuntimeEnvironmentInterface; @@ -46,7 +48,6 @@ use FastForward\DevTools\SelfUpdate\VersionCheckNotifierInterface; use FastForward\DevTools\SelfUpdate\WorkingDirectorySwitcher; use FastForward\DevTools\SelfUpdate\WorkingDirectorySwitcherInterface; -use FastForward\DevTools\ServiceProvider\DevToolsServiceProvider; use Override; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; @@ -57,7 +58,6 @@ use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use ReflectionMethod; -use ReflectionProperty; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\CompleteCommand; use Symfony\Component\Console\Command\DumpCompletionCommand; @@ -83,6 +83,7 @@ #[UsesClass(ClassReflection::class)] #[UsesClass(LogLevelOutputFormatter::class)] #[UsesClass(GithubActionOutput::class)] +#[UsesClass(ContainerFactory::class)] #[UsesClass(RuntimeEnvironment::class)] #[UsesClass(ColorPreservingProcessEnvironmentConfigurator::class)] #[UsesClass(CompositeProcessEnvironmentConfigurator::class)] @@ -143,6 +144,7 @@ final class DevToolsTest extends TestCase #[Override] protected function setUp(): void { + ContainerFactory::reset(); $this->commandLoader = $this->prophesize(CommandLoaderInterface::class); $this->commandLoader->getNames() ->willReturn([]); @@ -166,6 +168,7 @@ protected function setUp(): void #[Override] protected function tearDown(): void { + ContainerFactory::reset(); if (false === $this->originalWorkspaceDirectoryEnv) { putenv(ManagedWorkspace::ENV_WORKSPACE_DIR); @@ -503,15 +506,14 @@ public function __construct() * @return void */ #[Test] - public function createWillReturnInstanceOfDevTools(): void + public function containerFactoryWillReturnASharedDevToolsInstance(): void { - $reflectionProperty = new ReflectionProperty(DevTools::class, 'container'); - $reflectionProperty->setValue(null, null); + ContainerFactory::reset(); - $devTools = DevTools::create(); + $devTools = ContainerFactory::get(DevTools::class); self::assertInstanceOf(DevTools::class, $devTools); - self::assertSame($devTools, DevTools::create()); + self::assertSame($devTools, ContainerFactory::get(DevTools::class)); } /** diff --git a/tests/Console/Input/HasJsonOptionTest.php b/tests/Console/Input/HasJsonOptionTest.php new file mode 100644 index 0000000000..2b09fce6cf --- /dev/null +++ b/tests/Console/Input/HasJsonOptionTest.php @@ -0,0 +1,165 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Console\Input; + +use FastForward\DevTools\Console\Input\HasJsonOption; +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Environment\Environment as DevToolsEnvironment; +use FastForward\DevTools\Environment\RuntimeEnvironment; +use FastForward\DevTools\Environment\RuntimeEnvironmentInterface; +use FastForward\DevTools\Path\DevToolsPathResolver; +use PHPUnit\Framework\Attributes\CoversTrait; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Console\Input\InputInterface; + +use function Safe\putenv; + +#[CoversTrait(HasJsonOption::class)] +#[UsesClass(ContainerFactory::class)] +#[UsesClass(DevToolsPathResolver::class)] +#[UsesClass(DevToolsServiceProvider::class)] +#[UsesClass(DevToolsEnvironment::class)] +#[UsesClass(RuntimeEnvironment::class)] +final class HasJsonOptionTest extends TestCase +{ + use ProphecyTrait; + + /** + * @var array + */ + private array $server; + + /** + * @var array + */ + private array $environment; + + private string|false $composerTestsAreRunning; + + /** + * @return void + */ + protected function setUp(): void + { + ContainerFactory::reset(); + $this->server = $_SERVER; + $this->environment = $_ENV; + $this->composerTestsAreRunning = getenv('COMPOSER_TESTS_ARE_RUNNING'); + + $_SERVER = []; + $_ENV = []; + putenv('COMPOSER_TESTS_ARE_RUNNING'); + } + + /** + * @return void + */ + protected function tearDown(): void + { + ContainerFactory::reset(); + $_SERVER = $this->server; + $_ENV = $this->environment; + + if (false === $this->composerTestsAreRunning) { + putenv('COMPOSER_TESTS_ARE_RUNNING'); + + return; + } + + putenv('COMPOSER_TESTS_ARE_RUNNING=' . $this->composerTestsAreRunning); + } + + /** + * @return void + */ + #[Test] + public function isJsonOutputWillUseRuntimeEnvironmentWhenAvailable(): void + { + $runtimeEnvironment = $this->prophesize(RuntimeEnvironmentInterface::class); + $runtimeEnvironment->isAgentPresent() + ->willReturn(true); + $runtimeEnvironment->isComposerTestRun() + ->willReturn(false); + + $input = $this->prophesize(InputInterface::class); + $input->getOption('pretty-json') + ->willReturn(false); + $input->getOption('json') + ->willReturn(false); + + $command = new readonly class ($runtimeEnvironment->reveal()) { + use HasJsonOption; + + /** + * @param RuntimeEnvironmentInterface $runtimeEnvironment + */ + public function __construct( + private RuntimeEnvironmentInterface $runtimeEnvironment, + ) {} + + /** + * @param InputInterface $input + * + * @return bool + */ + public function isStructured(InputInterface $input): bool + { + return $this->isJsonOutput($input); + } + }; + + self::assertTrue($command->isStructured($input->reveal())); + } + + /** + * @return void + */ + #[Test] + public function isJsonOutputWillIgnoreFallbackAgentDetectionDuringPhpUnitRuns(): void + { + $_SERVER['CODEX_CI'] = '1'; + + $input = $this->prophesize(InputInterface::class); + $input->getOption('pretty-json') + ->willReturn(false); + $input->getOption('json') + ->willReturn(false); + + $command = new class { + use HasJsonOption; + + /** + * @param InputInterface $input + * + * @return bool + */ + public function isStructured(InputInterface $input): bool + { + return $this->isJsonOutput($input); + } + }; + + self::assertFalse($command->isStructured($input->reveal())); + } +} diff --git a/tests/Console/Logger/OutputFormatLoggerTest.php b/tests/Console/Logger/OutputFormatLoggerTest.php index a5bf3de286..8e49a7ae48 100644 --- a/tests/Console/Logger/OutputFormatLoggerTest.php +++ b/tests/Console/Logger/OutputFormatLoggerTest.php @@ -21,7 +21,6 @@ use stdClass; use DateTimeImmutable; -use Ergebnis\AgentDetector\Detector; use FastForward\DevTools\Console\Logger\OutputFormatLogger; use FastForward\DevTools\Console\Logger\Processor\CommandInputProcessor; use FastForward\DevTools\Console\Logger\Processor\CommandOutputProcessor; @@ -38,10 +37,11 @@ use Psr\Clock\ClockInterface; use Stringable; use Symfony\Component\Console\Input\ArgvInput; +use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\OutputInterface; -use function Safe\putenv; +use function Safe\json_decode; #[CoversClass(OutputFormatLogger::class)] #[UsesClass(CommandInputProcessor::class)] @@ -63,29 +63,21 @@ final class OutputFormatLoggerTest extends TestCase */ private ObjectProphecy $environment; - /** - * @var array - */ - private array $server; - - private string|false $composerTestsAreRunningEnv; - /** * @return void */ protected function setUp(): void { - $this->server = $_SERVER; - $_SERVER = []; - $this->composerTestsAreRunningEnv = getenv('COMPOSER_TESTS_ARE_RUNNING'); - putenv('COMPOSER_TESTS_ARE_RUNNING=1'); - $this->output = $this->prophesize(ConsoleOutputInterface::class); $this->errorOutput = $this->prophesize(OutputInterface::class); $this->clock = $this->prophesize(ClockInterface::class); $this->environment = $this->prophesize(RuntimeEnvironmentInterface::class); $this->environment->isGithubActions() ->willReturn(false); + $this->environment->isAgentPresent() + ->willReturn(false); + $this->environment->isComposerTestRun() + ->willReturn(false); $this->output->getErrorOutput() ->willReturn($this->errorOutput->reveal()); @@ -103,7 +95,7 @@ public function logWillWriteInterpolatedInfoMessagesToStandardOutput(): void new ArgvInput(['dev-tools']), $this->output->reveal(), $this->clock->reveal(), - new Detector(), + $this->environment->reveal(), new CompositeContextProcessor([new CommandInputProcessor(), new CommandOutputProcessor()]), $this->createGithubActionOutput(), ); @@ -139,7 +131,7 @@ public function logWillWriteErrorMessagesToErrorOutput(): void new ArgvInput(['dev-tools']), $this->output->reveal(), $this->clock->reveal(), - new Detector(), + $this->environment->reveal(), new CompositeContextProcessor([new CommandInputProcessor(), new CommandOutputProcessor()]), $this->createGithubActionOutput(), ); @@ -168,7 +160,7 @@ public function logWillWriteStructuredJsonWhenJsonOutputIsRequested(): void new ArgvInput(['dev-tools', '--json']), $this->output->reveal(), $this->clock->reveal(), - new Detector(), + $this->environment->reveal(), new CompositeContextProcessor([new CommandInputProcessor(), new CommandOutputProcessor()]), $this->createGithubActionOutput(), ); @@ -185,6 +177,32 @@ public function logWillWriteStructuredJsonWhenJsonOutputIsRequested(): void ]); } + /** + * @return void + */ + #[Test] + public function logWillEmitParseableJsonWhenJsonOutputIsRequested(): void + { + $logger = new OutputFormatLogger( + new ArgvInput(['dev-tools', '--json']), + $this->output->reveal(), + $this->clock->reveal(), + $this->environment->reveal(), + new CompositeContextProcessor([new CommandInputProcessor(), new CommandOutputProcessor()]), + $this->createGithubActionOutput(), + ); + + $this->output->writeln(Argument::that(static function (string $payload): bool { + $decoded = json_decode($payload, true, 512, \JSON_THROW_ON_ERROR); + + return 'info' === $decoded['level'] && 'Build {status}' === $decoded['message']; + }))->shouldBeCalledOnce(); + + $logger->info('Build {status}', [ + 'status' => 'ready', + ]); + } + /** * @return void */ @@ -195,7 +213,7 @@ public function logWillWritePrettyPrintedJsonWhenPrettyJsonOutputIsRequested(): new ArgvInput(['dev-tools', '--pretty-json']), $this->output->reveal(), $this->clock->reveal(), - new Detector(), + $this->environment->reveal(), new CompositeContextProcessor([new CommandInputProcessor(), new CommandOutputProcessor()]), $this->createGithubActionOutput(), ); @@ -212,19 +230,90 @@ public function logWillWritePrettyPrintedJsonWhenPrettyJsonOutputIsRequested(): ]); } + /** + * @return void + */ + #[Test] + public function logWillEmitParseableJsonWhenPrettyJsonOutputIsRequested(): void + { + $logger = new OutputFormatLogger( + new ArgvInput(['dev-tools', '--pretty-json']), + $this->output->reveal(), + $this->clock->reveal(), + $this->environment->reveal(), + new CompositeContextProcessor([new CommandInputProcessor(), new CommandOutputProcessor()]), + $this->createGithubActionOutput(), + ); + + $this->output->writeln(Argument::that(static function (string $payload): bool { + $decoded = json_decode($payload, true, 512, \JSON_THROW_ON_ERROR); + + return 'info' === $decoded['level'] && 'Build {status}' === $decoded['message']; + }))->shouldBeCalledOnce(); + + $logger->info('Build {status}', [ + 'status' => 'ready', + ]); + } + + /** + * @return void + */ + #[Test] + public function logWillEmbedDecodedStructuredCommandOutputInsteadOfEscapedJsonStrings(): void + { + $logger = new OutputFormatLogger( + new ArgvInput(['dev-tools', '--pretty-json']), + $this->output->reveal(), + $this->clock->reveal(), + $this->environment->reveal(), + new CompositeContextProcessor([new CommandInputProcessor(), new CommandOutputProcessor()]), + $this->createGithubActionOutput(), + ); + $commandOutput = new BufferedOutput(); + $commandOutput->write( + "{\"message\":\"docs\"}\n{\"message\":\"tests\",\"context\":{\"output\":{\"result\":\"success\"}}}\n" + ); + + $this->output->writeln(Argument::that(static function (string $payload): bool { + $decoded = json_decode($payload, true, 512, \JSON_THROW_ON_ERROR); + + return [ + [ + 'message' => 'docs', + ], + [ + 'message' => 'tests', + 'context' => [ + 'output' => [ + 'result' => 'success', + ], + ], + ], + ] === $decoded['context']['output']; + }))->shouldBeCalledOnce(); + + $logger->info('Reports ready.', [ + 'output' => $commandOutput, + ]); + } + /** * @return void */ #[Test] public function logWillWriteStructuredJsonWhenAgentEnvironmentIsDetected(): void { - $_SERVER['CODEX_THREAD_ID'] = 'thread-123'; + $this->environment->isAgentPresent() + ->willReturn(true); + $this->environment->isComposerTestRun() + ->willReturn(false); $logger = new OutputFormatLogger( new ArgvInput(['dev-tools']), $this->output->reveal(), $this->clock->reveal(), - new Detector(), + $this->environment->reveal(), new CompositeContextProcessor([new CommandInputProcessor(), new CommandOutputProcessor()]), $this->createGithubActionOutput(), ); @@ -243,17 +332,29 @@ public function logWillWriteStructuredJsonWhenAgentEnvironmentIsDetected(): void /** * @return void */ - protected function tearDown(): void + #[Test] + public function logWillKeepPlainTextOutputWhenAgentEnvironmentIsDetectedDuringComposerTests(): void { - $_SERVER = $this->server; + $this->environment->isAgentPresent() + ->willReturn(true); + $this->environment->isComposerTestRun() + ->willReturn(true); - if (false === $this->composerTestsAreRunningEnv) { - putenv('COMPOSER_TESTS_ARE_RUNNING'); + $logger = new OutputFormatLogger( + new ArgvInput(['dev-tools']), + $this->output->reveal(), + $this->clock->reveal(), + $this->environment->reveal(), + new CompositeContextProcessor([new CommandInputProcessor(), new CommandOutputProcessor()]), + $this->createGithubActionOutput(), + ); - return; - } + $this->output->writeln('2026-04-21T16:00:00+00:00 [INFO] Agent ready') + ->shouldBeCalledOnce(); + $this->errorOutput->writeln(Argument::type('string')) + ->shouldNotBeCalled(); - putenv('COMPOSER_TESTS_ARE_RUNNING=' . $this->composerTestsAreRunningEnv); + $logger->info('Agent ready'); } /** diff --git a/tests/Console/Logger/Processor/CommandOutputProcessorTest.php b/tests/Console/Logger/Processor/CommandOutputProcessorTest.php index d75540c1ef..8373872178 100644 --- a/tests/Console/Logger/Processor/CommandOutputProcessorTest.php +++ b/tests/Console/Logger/Processor/CommandOutputProcessorTest.php @@ -49,6 +49,174 @@ public function processWillReplaceBufferedOutputWithItsContents(): void self::assertSame("done\n", $context['output']); } + /** + * @return void + */ + #[Test] + public function processWillDecodeSingleJsonBufferedOutput(): void + { + $processor = new CommandOutputProcessor(); + $output = new BufferedOutput(); + $output->write("{\n \"result\": \"success\",\n \"summary\": {\n \"tests\": 2\n }\n}\n"); + + $context = $processor->process([ + 'output' => $output, + ]); + + self::assertSame([ + 'result' => 'success', + 'summary' => [ + 'tests' => 2, + ], + ], $context['output']); + } + + /** + * @return void + */ + #[Test] + public function processWillNormalizeRectorChangedFilesWhenNoFilesActuallyChanged(): void + { + $processor = new CommandOutputProcessor(); + $output = new BufferedOutput(); + $output->write( + "{\"totals\":{\"changed_files\":0,\"errors\":0},\"changed_files\":[\"src/Foo.php\",\"src/Bar.php\"]}\n" + ); + + $context = $processor->process([ + 'output' => $output, + ]); + + self::assertSame([ + 'totals' => [ + 'changed_files' => 0, + 'errors' => 0, + ], + 'changed_files' => [], + ], $context['output']); + } + + /** + * @return void + */ + #[Test] + public function processWillNormalizeRectorChangedFilesUsingOnlyDiffEntries(): void + { + $processor = new CommandOutputProcessor(); + $output = new BufferedOutput(); + $output->write( + "{\"totals\":{\"changed_files\":1,\"errors\":0},\"changed_files\":[\"src/Foo.php\",\"src/Bar.php\"],\"file_diffs\":[{\"file\":\"src/Bar.php\",\"diff\":\"@@\"}]}\n" + ); + + $context = $processor->process([ + 'output' => $output, + ]); + + self::assertSame([ + 'totals' => [ + 'changed_files' => 1, + 'errors' => 0, + ], + 'changed_files' => ['src/Bar.php'], + 'file_diffs' => [ + [ + 'file' => 'src/Bar.php', + 'diff' => '@@', + ], + ], + ], $context['output']); + } + + /** + * @return void + */ + #[Test] + public function processWillDecodeMultipleJsonBufferedOutputsIntoAList(): void + { + $processor = new CommandOutputProcessor(); + $output = new BufferedOutput(); + $output->write( + "{\"message\":\"docs\"}\n{\"message\":\"tests\",\"context\":{\"output\":{\"result\":\"success\"}}}\n" + ); + + $context = $processor->process([ + 'output' => $output, + ]); + + self::assertSame([ + [ + 'message' => 'docs', + ], + [ + 'message' => 'tests', + 'context' => [ + 'output' => [ + 'result' => 'success', + ], + ], + ], + ], $context['output']); + } + + /** + * @return void + */ + #[Test] + public function processWillDiscardPlainTextPreambleBeforeStructuredJsonOutput(): void + { + $processor = new CommandOutputProcessor(); + $output = new BufferedOutput(); + $output->write( + "Warning: advisory text before JSON.\n" + . "{\"about\":\"PHP CS Fixer\"}\n" + . "{\"totals\":{\"changed_files\":0,\"errors\":0},\"changed_files\":[\"src/Foo.php\"]}\n" + ); + + $context = $processor->process([ + 'output' => $output, + ]); + + self::assertSame([ + [ + 'about' => 'PHP CS Fixer', + ], + [ + 'totals' => [ + 'changed_files' => 0, + 'errors' => 0, + ], + 'changed_files' => [], + ], + ], $context['output']); + } + + /** + * @return void + */ + #[Test] + public function processWillDecodeTheFinalStructuredJsonAfterPlainTextToolOutput(): void + { + $processor = new CommandOutputProcessor(); + $output = new BufferedOutput(); + $output->write( + "composer-normalize warning before machine output.\n" + . "Another advisory line.\n" + . "{\"totals\":{\"changed_files\":0,\"errors\":0},\"changed_files\":[\"src/Foo.php\"]}\n" + ); + + $context = $processor->process([ + 'output' => $output, + ]); + + self::assertSame([ + 'totals' => [ + 'changed_files' => 0, + 'errors' => 0, + ], + 'changed_files' => [], + ], $context['output']); + } + /** * @return void */ diff --git a/tests/Container/ContainerFactoryTest.php b/tests/Container/ContainerFactoryTest.php new file mode 100644 index 0000000000..fed62c6d45 --- /dev/null +++ b/tests/Container/ContainerFactoryTest.php @@ -0,0 +1,96 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Container; + +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Path\DevToolsPathResolver; +use FastForward\DevTools\Path\WorkingProjectPathResolver; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use stdClass; +use Symfony\Component\Config\FileLocatorInterface; + +#[UsesClass(DevToolsPathResolver::class)] +#[UsesClass(WorkingProjectPathResolver::class)] +#[CoversClass(ContainerFactory::class)] +#[UsesClass(DevToolsServiceProvider::class)] +final class ContainerFactoryTest extends TestCase +{ + /** + * @return void + */ + protected function setUp(): void + { + ContainerFactory::reset(); + } + + /** + * @return void + */ + protected function tearDown(): void + { + ContainerFactory::reset(); + } + + /** + * @return void + */ + #[Test] + public function createWillReturnTheSharedContainerInstance(): void + { + self::assertSame(ContainerFactory::create(), ContainerFactory::create()); + } + + /** + * @return void + */ + #[Test] + public function getWillResolveServicesFromTheSharedContainer(): void + { + self::assertInstanceOf(FileLocatorInterface::class, ContainerFactory::get(FileLocatorInterface::class)); + } + + /** + * @return void + */ + #[Test] + public function hasWillReturnWhetherTheSharedContainerCanResolveAService(): void + { + self::assertTrue(ContainerFactory::has(FileLocatorInterface::class)); + self::assertFalse(ContainerFactory::has('dev-tools.missing-service')); + } + + /** + * @return void + */ + #[Test] + public function setWillOverrideSharedContainerEntries(): void + { + $service = new stdClass(); + + ContainerFactory::set('dev-tools.custom-service', $service); + + self::assertTrue(ContainerFactory::has('dev-tools.custom-service')); + self::assertSame($service, ContainerFactory::get('dev-tools.custom-service')); + } +} diff --git a/tests/Container/UsesContainerFactory.php b/tests/Container/UsesContainerFactory.php new file mode 100644 index 0000000000..13cad9f121 --- /dev/null +++ b/tests/Container/UsesContainerFactory.php @@ -0,0 +1,63 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Container; + +use FastForward\DevTools\Container\ContainerFactory; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Environment\Environment; +use FastForward\DevTools\Environment\RuntimeEnvironment; +use FastForward\DevTools\Path\DevToolsPathResolver; +use PHPUnit\Framework\Attributes\AfterClass; +use PHPUnit\Framework\Attributes\BeforeClass; +use PHPUnit\Framework\Attributes\UsesClass; + +/** + * Resets the shared DevTools container around a test class lifecycle. + */ +#[UsesClass(ContainerFactory::class)] +#[UsesClass(DevToolsPathResolver::class)] +#[UsesClass(DevToolsServiceProvider::class)] +#[UsesClass(Environment::class)] +#[UsesClass(RuntimeEnvironment::class)] +trait UsesContainerFactory +{ + /** + * @return void + */ + #[BeforeClass] + #[AfterClass] + public static function resetSharedContainer(): void + { + ContainerFactory::reset(); + } + + /** + * Overrides a shared container entry for the current test case. + * + * @param string $id + * @param mixed $value + * + * @return void + */ + protected function setContainerEntry(string $id, mixed $value): void + { + ContainerFactory::set($id, $value); + } +} diff --git a/tests/Environment/RuntimeEnvironmentTest.php b/tests/Environment/RuntimeEnvironmentTest.php index f297c11b8c..5fde743390 100644 --- a/tests/Environment/RuntimeEnvironmentTest.php +++ b/tests/Environment/RuntimeEnvironmentTest.php @@ -155,6 +155,15 @@ public function isComposerTestRunWillReturnWhetherComposerTestsFlagIsEnabled(): self::assertTrue($this->runtimeEnvironment->isComposerTestRun()); } + /** + * @return void + */ + #[Test] + public function isComposerTestRunWillReturnTrueDuringPhpUnitRuntime(): void + { + self::assertTrue($this->runtimeEnvironment->isComposerTestRun()); + } + /** * @return void */ diff --git a/tests/ServiceProvider/DevToolsServiceProviderTest.php b/tests/ServiceProvider/DevToolsServiceProviderTest.php index aa18e58850..392fe573b4 100644 --- a/tests/ServiceProvider/DevToolsServiceProviderTest.php +++ b/tests/ServiceProvider/DevToolsServiceProviderTest.php @@ -20,9 +20,9 @@ namespace FastForward\DevTools\Tests\ServiceProvider; use DI\Container; +use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; use FastForward\DevTools\Path\DevToolsPathResolver; use FastForward\DevTools\Path\WorkingProjectPathResolver; -use FastForward\DevTools\ServiceProvider\DevToolsServiceProvider; use Interop\Container\ServiceProviderInterface; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test;