From 94fd43523061ff7f59fe9cb33cd98094c63fd457 Mon Sep 17 00:00:00 2001 From: Matthew Grasmick Date: Wed, 10 Jun 2026 23:33:04 -0400 Subject: [PATCH 01/17] Update minor dependencies: guzzle, phplint, slevomat/coding-standard, thecodingmachine/safe Loosens the exact thecodingmachine/safe pin to ^3.4 now that the de-aliased 3.x line is stable. Co-Authored-By: Claude Fable 5 --- composer.json | 2 +- composer.lock | 118 ++++++++++++++++++++++++++------------------------ 2 files changed, 62 insertions(+), 58 deletions(-) diff --git a/composer.json b/composer.json index 25931e71..579e8271 100644 --- a/composer.json +++ b/composer.json @@ -51,7 +51,7 @@ "symfony/process": "^6.4", "symfony/validator": "^6.4", "symfony/yaml": "^6.4", - "thecodingmachine/safe": "3.0.2", + "thecodingmachine/safe": "^3.4", "typhonius/acquia-logstream": "^0.0.15", "typhonius/acquia-php-sdk-v2": "^3.8.0", "vlucas/phpdotenv": "^5.5", diff --git a/composer.lock b/composer.lock index 93444317..49bfcdc0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1a77bea93c21f1af1d49773a69642456", + "content-hash": "d7ff4aa373521a5234cd573c59591675", "packages": [ { "name": "acquia/drupal-environment-detector", @@ -697,16 +697,16 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.11.0", + "version": "7.11.1", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "c987f8ce84b8434fa430795eca0f3430663da72b" + "reference": "5af96f374e0ab4ebd747b8310888c99d3adb0a8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/c987f8ce84b8434fa430795eca0f3430663da72b", - "reference": "c987f8ce84b8434fa430795eca0f3430663da72b", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/5af96f374e0ab4ebd747b8310888c99d3adb0a8c", + "reference": "5af96f374e0ab4ebd747b8310888c99d3adb0a8c", "shasum": "" }, "require": { @@ -725,7 +725,7 @@ "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", "guzzle/client-integration-tests": "3.0.2", - "guzzlehttp/test-server": "^0.4", + "guzzlehttp/test-server": "^0.5", "php-http/message-factory": "^1.1", "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" @@ -805,7 +805,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.11.0" + "source": "https://github.com/guzzle/guzzle/tree/7.11.1" }, "funding": [ { @@ -821,7 +821,7 @@ "type": "tidelift" } ], - "time": "2026-06-02T12:40:51+00:00" + "time": "2026-06-07T22:54:06+00:00" }, { "name": "guzzlehttp/promises", @@ -3599,16 +3599,16 @@ }, { "name": "symfony/cache", - "version": "v6.4.40", + "version": "v6.4.41", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "8f9b022e63fa02bd984c06dc886039936ea17714" + "reference": "5490a577195422c3c9cda09c64823580858af854" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/8f9b022e63fa02bd984c06dc886039936ea17714", - "reference": "8f9b022e63fa02bd984c06dc886039936ea17714", + "url": "https://api.github.com/repos/symfony/cache/zipball/5490a577195422c3c9cda09c64823580858af854", + "reference": "5490a577195422c3c9cda09c64823580858af854", "shasum": "" }, "require": { @@ -3675,7 +3675,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v6.4.40" + "source": "https://github.com/symfony/cache/tree/v6.4.41" }, "funding": [ { @@ -3695,7 +3695,7 @@ "type": "tidelift" } ], - "time": "2026-05-19T20:33:22+00:00" + "time": "2026-05-24T08:42:40+00:00" }, { "name": "symfony/cache-contracts", @@ -3858,16 +3858,16 @@ }, { "name": "symfony/console", - "version": "v6.4.39", + "version": "v6.4.41", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "c132f1215fe4aa45b70173cc00ce9a755dd31ec5" + "reference": "d21b17ed158e79180fac3895ff751707970eeb57" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/c132f1215fe4aa45b70173cc00ce9a755dd31ec5", - "reference": "c132f1215fe4aa45b70173cc00ce9a755dd31ec5", + "url": "https://api.github.com/repos/symfony/console/zipball/d21b17ed158e79180fac3895ff751707970eeb57", + "reference": "d21b17ed158e79180fac3895ff751707970eeb57", "shasum": "" }, "require": { @@ -3932,7 +3932,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.39" + "source": "https://github.com/symfony/console/tree/v6.4.41" }, "funding": [ { @@ -3952,7 +3952,7 @@ "type": "tidelift" } ], - "time": "2026-05-12T06:50:03+00:00" + "time": "2026-05-24T08:48:41+00:00" }, { "name": "symfony/dependency-injection", @@ -5069,16 +5069,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.38.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "9c862df890f7c833b1101ac5578ec4dcf199efb5" + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/9c862df890f7c833b1101ac5578ec4dcf199efb5", - "reference": "9c862df890f7c833b1101ac5578ec4dcf199efb5", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", "shasum": "" }, "require": { @@ -5127,7 +5127,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" }, "funding": [ { @@ -5147,7 +5147,7 @@ "type": "tidelift" } ], - "time": "2026-05-25T12:39:52+00:00" + "time": "2026-05-26T05:58:03+00:00" }, { "name": "symfony/polyfill-intl-normalizer", @@ -5236,16 +5236,16 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.38.1", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", - "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { @@ -5297,7 +5297,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "funding": [ { @@ -5317,7 +5317,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T12:51:13+00:00" + "time": "2026-05-27T06:59:30+00:00" }, { "name": "symfony/polyfill-php80", @@ -5645,16 +5645,16 @@ }, { "name": "symfony/process", - "version": "v6.4.39", + "version": "v6.4.41", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "6c93071cb8c91dce5a41960d125e019e64ef6cb5" + "reference": "c8fc09bdfe9fde9aaa89b415a4477feaccec16a7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/6c93071cb8c91dce5a41960d125e019e64ef6cb5", - "reference": "6c93071cb8c91dce5a41960d125e019e64ef6cb5", + "url": "https://api.github.com/repos/symfony/process/zipball/c8fc09bdfe9fde9aaa89b415a4477feaccec16a7", + "reference": "c8fc09bdfe9fde9aaa89b415a4477feaccec16a7", "shasum": "" }, "require": { @@ -5686,7 +5686,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v6.4.39" + "source": "https://github.com/symfony/process/tree/v6.4.41" }, "funding": [ { @@ -5706,7 +5706,7 @@ "type": "tidelift" } ], - "time": "2026-05-11T16:53:15+00:00" + "time": "2026-05-23T13:47:21+00:00" }, { "name": "symfony/service-contracts", @@ -5797,16 +5797,16 @@ }, { "name": "symfony/string", - "version": "v7.4.11", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "965f7306a43383d02c6aca1e3f3bd2f0ea5dee15" + "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/965f7306a43383d02c6aca1e3f3bd2f0ea5dee15", - "reference": "965f7306a43383d02c6aca1e3f3bd2f0ea5dee15", + "url": "https://api.github.com/repos/symfony/string/zipball/961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", + "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", "shasum": "" }, "require": { @@ -5864,7 +5864,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.4.11" + "source": "https://github.com/symfony/string/tree/v7.4.13" }, "funding": [ { @@ -5884,7 +5884,7 @@ "type": "tidelift" } ], - "time": "2026-05-13T12:04:42+00:00" + "time": "2026-05-23T15:23:29+00:00" }, { "name": "symfony/translation-contracts", @@ -6239,16 +6239,16 @@ }, { "name": "symfony/yaml", - "version": "v6.4.40", + "version": "v6.4.41", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "68dcd1f1602dac9d9221e25729683e0ce8733f3b" + "reference": "e8fdf3408c85806198d5826e604ffc6830d33152" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/68dcd1f1602dac9d9221e25729683e0ce8733f3b", - "reference": "68dcd1f1602dac9d9221e25729683e0ce8733f3b", + "url": "https://api.github.com/repos/symfony/yaml/zipball/e8fdf3408c85806198d5826e604ffc6830d33152", + "reference": "e8fdf3408c85806198d5826e604ffc6830d33152", "shasum": "" }, "require": { @@ -6291,7 +6291,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v6.4.40" + "source": "https://github.com/symfony/yaml/tree/v6.4.41" }, "funding": [ { @@ -6311,20 +6311,20 @@ "type": "tidelift" } ], - "time": "2026-05-19T20:33:22+00:00" + "time": "2026-05-25T06:03:23+00:00" }, { "name": "thecodingmachine/safe", - "version": "v3.0.2", + "version": "v3.4.0", "source": { "type": "git", "url": "https://github.com/thecodingmachine/safe.git", - "reference": "22ffad3248982a784f9870a37aeb2e522bd19645" + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/22ffad3248982a784f9870a37aeb2e522bd19645", - "reference": "22ffad3248982a784f9870a37aeb2e522bd19645", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19", "shasum": "" }, "require": { @@ -6434,7 +6434,7 @@ "description": "PHP core functions that throw exceptions instead of returning FALSE on error", "support": { "issues": "https://github.com/thecodingmachine/safe/issues", - "source": "https://github.com/thecodingmachine/safe/tree/v3.0.2" + "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0" }, "funding": [ { @@ -6445,12 +6445,16 @@ "url": "https://github.com/shish", "type": "github" }, + { + "url": "https://github.com/silasjoisten", + "type": "github" + }, { "url": "https://github.com/staabm", "type": "github" } ], - "time": "2025-02-19T19:23:00+00:00" + "time": "2026-02-04T18:08:13+00:00" }, { "name": "typhonius/acquia-logstream", @@ -14029,5 +14033,5 @@ "platform-overrides": { "php": "8.2.29" }, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } From f8d792af85661f9229a3e186fcbb0b0ce8c6f97f Mon Sep 17 00:00:00 2001 From: Matthew Grasmick Date: Wed, 10 Jun 2026 23:35:27 -0400 Subject: [PATCH 02/17] Upgrade PHPStan to 2.x with deprecation-rules and prophecy extensions PHPStan 2 uses 50-70% less memory and unlocks levels up to 10. Analysis still passes at the configured level. Co-Authored-By: Claude Fable 5 --- composer.json | 6 ++-- composer.lock | 77 +++++++++++++++++++++++++++++++-------------------- 2 files changed, 50 insertions(+), 33 deletions(-) diff --git a/composer.json b/composer.json index 579e8271..3c2595d4 100644 --- a/composer.json +++ b/composer.json @@ -63,15 +63,15 @@ "dealerdirect/phpcodesniffer-composer-installer": "^1.0.0", "dominikb/composer-license-checker": "^2.4", "infection/infection": "^0.31.2", - "jangregor/phpstan-prophecy": "^1.0", + "jangregor/phpstan-prophecy": "^2.0", "mikey179/vfsstream": "^1.6", "overtrue/phplint": "^9.6.2", "phpro/grumphp": "^2.9.0", "phpspec/prophecy": "^1.17", "phpspec/prophecy-phpunit": "^2.0", "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.0", - "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0", "phpunit/phpunit": "^11", "slevomat/coding-standard": "^8.10", "squizlabs/php_codesniffer": "^3.5", diff --git a/composer.lock b/composer.lock index 49bfcdc0..3407cee0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d7ff4aa373521a5234cd573c59591675", + "content-hash": "e3d0f03c5e5e55161eb0dffb38ffc236", "packages": [ { "name": "acquia/drupal-environment-detector", @@ -9206,32 +9206,35 @@ }, { "name": "jangregor/phpstan-prophecy", - "version": "1.0.2", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/Jan0707/phpstan-prophecy.git", - "reference": "5ee56c7db1d58f0578c82a35e3c1befe840e85a9" + "reference": "42b6e62dc0fa5724a667cb9a10c7245580f22033" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Jan0707/phpstan-prophecy/zipball/5ee56c7db1d58f0578c82a35e3c1befe840e85a9", - "reference": "5ee56c7db1d58f0578c82a35e3c1befe840e85a9", + "url": "https://api.github.com/repos/Jan0707/phpstan-prophecy/zipball/42b6e62dc0fa5724a667cb9a10c7245580f22033", + "reference": "42b6e62dc0fa5724a667cb9a10c7245580f22033", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0", - "phpstan/phpstan": "^1.0.0" + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.5" }, "conflict": { - "phpspec/prophecy": "<1.7.0 || >=2.0.0", - "phpunit/phpunit": "<6.0.0 || >=12.0.0" + "phpspec/prophecy": "<1.17.0 || >=2.0.0", + "phpspec/prophecy-phpunit": "<2.3.0 || >=3.0.0", + "phpunit/phpunit": "<9.1.0 || >=14.0.0" }, "require-dev": { - "ergebnis/composer-normalize": "^2.1.1", - "ergebnis/license": "^1.0.0", - "ergebnis/php-cs-fixer-config": "~2.2.0", + "ergebnis/composer-normalize": "^2.50.0", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.59.0", + "php-cs-fixer/shim": "^3.93.0", "phpspec/prophecy": "^1.7.0", - "phpunit/phpunit": "^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0" + "phpspec/prophecy-phpunit": "^2.3", + "phpunit/phpunit": "^9.1.0" }, "type": "phpstan-extension", "extra": { @@ -9259,9 +9262,9 @@ "description": "Provides a phpstan/phpstan extension for phpspec/prophecy", "support": { "issues": "https://github.com/Jan0707/phpstan-prophecy/issues", - "source": "https://github.com/Jan0707/phpstan-prophecy/tree/1.0.2" + "source": "https://github.com/Jan0707/phpstan-prophecy/tree/2.3.0" }, - "time": "2024-04-03T08:15:54+00:00" + "time": "2026-02-10T08:07:43+00:00" }, { "name": "jean85/pretty-package-versions", @@ -11030,15 +11033,15 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.33", + "version": "2.2.2", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/37982d6fc7cbb746dda7773530cda557cdf119e1", - "reference": "37982d6fc7cbb746dda7773530cda557cdf119e1", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e5cc34d491a90e79c216d824f60fe21fd4d93bd6", + "reference": "e5cc34d491a90e79c216d824f60fe21fd4d93bd6", "shasum": "" }, "require": { - "php": "^7.2|^8.0" + "php": "^7.4|^8.0" }, "conflict": { "phpstan/phpstan-shim": "*" @@ -11057,6 +11060,17 @@ "license": [ "MIT" ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], "description": "PHPStan - PHP Static Analysis Tool", "keywords": [ "dev", @@ -11079,30 +11093,30 @@ "type": "github" } ], - "time": "2026-02-28T20:30:03+00:00" + "time": "2026-06-05T09:00:01+00:00" }, { "name": "phpstan/phpstan-deprecation-rules", - "version": "1.2.1", + "version": "2.0.4", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-deprecation-rules.git", - "reference": "f94d246cc143ec5a23da868f8f7e1393b50eaa82" + "reference": "6b5571001a7f04fa0422254c30a0017ec2f2cacc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-deprecation-rules/zipball/f94d246cc143ec5a23da868f8f7e1393b50eaa82", - "reference": "f94d246cc143ec5a23da868f8f7e1393b50eaa82", + "url": "https://api.github.com/repos/phpstan/phpstan-deprecation-rules/zipball/6b5571001a7f04fa0422254c30a0017ec2f2cacc", + "reference": "6b5571001a7f04fa0422254c30a0017ec2f2cacc", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0", - "phpstan/phpstan": "^1.12" + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.39" }, "require-dev": { "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/phpstan-phpunit": "^1.0", - "phpunit/phpunit": "^9.5" + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6" }, "type": "phpstan-extension", "extra": { @@ -11122,11 +11136,14 @@ "MIT" ], "description": "PHPStan rules for detecting usage of deprecated classes, methods, properties, constants and traits.", + "keywords": [ + "static analysis" + ], "support": { "issues": "https://github.com/phpstan/phpstan-deprecation-rules/issues", - "source": "https://github.com/phpstan/phpstan-deprecation-rules/tree/1.2.1" + "source": "https://github.com/phpstan/phpstan-deprecation-rules/tree/2.0.4" }, - "time": "2024-09-11T15:52:35+00:00" + "time": "2026-02-09T13:21:14+00:00" }, { "name": "phpunit/php-code-coverage", From fe05729ceef1b6de0f00691ffa28b711426b85b5 Mon Sep 17 00:00:00 2001 From: Matthew Grasmick Date: Wed, 10 Jun 2026 23:36:00 -0400 Subject: [PATCH 03/17] Upgrade infection to 0.32 and composer-license-checker to 3.0 Co-Authored-By: Claude Fable 5 --- composer.json | 4 +- composer.lock | 177 +++++++++++++++++++++++++++----------------------- 2 files changed, 97 insertions(+), 84 deletions(-) diff --git a/composer.json b/composer.json index 3c2595d4..c052b660 100644 --- a/composer.json +++ b/composer.json @@ -61,8 +61,8 @@ "acquia/coding-standards": "^3.0.2", "brianium/paratest": "^7", "dealerdirect/phpcodesniffer-composer-installer": "^1.0.0", - "dominikb/composer-license-checker": "^2.4", - "infection/infection": "^0.31.2", + "dominikb/composer-license-checker": "^3.0", + "infection/infection": "^0.32", "jangregor/phpstan-prophecy": "^2.0", "mikey179/vfsstream": "^1.6", "overtrue/phplint": "^9.6.2", diff --git a/composer.lock b/composer.lock index 3407cee0..c2c7077b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e3d0f03c5e5e55161eb0dffb38ffc236", + "content-hash": "4632ff793bb98879116b188ff04b3776", "packages": [ { "name": "acquia/drupal-environment-detector", @@ -251,16 +251,16 @@ }, { "name": "composer/ca-bundle", - "version": "1.5.11", + "version": "1.5.12", "source": { "type": "git", "url": "https://github.com/composer/ca-bundle.git", - "reference": "68ff39175e8e94a4bb1d259407ce51a6a60f09e6" + "reference": "00a2f4201641d5c53f7fc0195e6c8d9fcc321a78" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/68ff39175e8e94a4bb1d259407ce51a6a60f09e6", - "reference": "68ff39175e8e94a4bb1d259407ce51a6a60f09e6", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/00a2f4201641d5c53f7fc0195e6c8d9fcc321a78", + "reference": "00a2f4201641d5c53f7fc0195e6c8d9fcc321a78", "shasum": "" }, "require": { @@ -307,7 +307,7 @@ "support": { "irc": "irc://irc.freenode.org/composer", "issues": "https://github.com/composer/ca-bundle/issues", - "source": "https://github.com/composer/ca-bundle/tree/1.5.11" + "source": "https://github.com/composer/ca-bundle/tree/1.5.12" }, "funding": [ { @@ -319,7 +319,7 @@ "type": "github" } ], - "time": "2026-03-30T09:16:10+00:00" + "time": "2026-05-19T11:26:22+00:00" }, { "name": "composer/semver", @@ -5565,16 +5565,16 @@ }, { "name": "symfony/polyfill-php85", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee" + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/fcfa4973a9917cef23f2e38774da74a2b7d115ee", - "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", "shasum": "" }, "require": { @@ -5621,7 +5621,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" }, "funding": [ { @@ -5641,7 +5641,7 @@ "type": "tidelift" } ], - "time": "2026-04-26T13:10:57+00:00" + "time": "2026-05-26T02:25:22+00:00" }, { "name": "symfony/process", @@ -7850,16 +7850,16 @@ }, { "name": "composer/composer", - "version": "2.9.8", + "version": "2.10.1", "source": { "type": "git", "url": "https://github.com/composer/composer.git", - "reference": "39ee8baff8e97a1b657bbfcd6a236ff93a5efbb2" + "reference": "4120703b9bda8795075047b40361d7ec4d2abe49" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/composer/zipball/39ee8baff8e97a1b657bbfcd6a236ff93a5efbb2", - "reference": "39ee8baff8e97a1b657bbfcd6a236ff93a5efbb2", + "url": "https://api.github.com/repos/composer/composer/zipball/4120703b9bda8795075047b40361d7ec4d2abe49", + "reference": "4120703b9bda8795075047b40361d7ec4d2abe49", "shasum": "" }, "require": { @@ -7912,7 +7912,7 @@ ] }, "branch-alias": { - "dev-main": "2.9-dev" + "dev-main": "2.10-dev" } }, "autoload": { @@ -7947,7 +7947,7 @@ "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/composer/issues", "security": "https://github.com/composer/composer/security/policy", - "source": "https://github.com/composer/composer/tree/2.9.8" + "source": "https://github.com/composer/composer/tree/2.10.1" }, "funding": [ { @@ -7959,7 +7959,7 @@ "type": "github" } ], - "time": "2026-05-13T07:28:38+00:00" + "time": "2026-06-04T08:25:59+00:00" }, { "name": "composer/metadata-minifier", @@ -8597,33 +8597,33 @@ }, { "name": "dominikb/composer-license-checker", - "version": "2.7.0", + "version": "3.0.0", "source": { "type": "git", "url": "https://github.com/dominikb/composer-license-checker.git", - "reference": "c94bda40a6cf37a98c8bf3494f8f655656724ce8" + "reference": "ae7cb2f0a7c5f581c9cf8188bcb2047b4dbc542a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dominikb/composer-license-checker/zipball/c94bda40a6cf37a98c8bf3494f8f655656724ce8", - "reference": "c94bda40a6cf37a98c8bf3494f8f655656724ce8", + "url": "https://api.github.com/repos/dominikb/composer-license-checker/zipball/ae7cb2f0a7c5f581c9cf8188bcb2047b4dbc542a", + "reference": "ae7cb2f0a7c5f581c9cf8188bcb2047b4dbc542a", "shasum": "" }, "require": { - "composer/composer": "~2.2.23 || ^2.7.0", + "composer/composer": "^2.7.7", "ext-json": "*", - "guzzlehttp/guzzle": "^6.5.8 || ^7.4.5", - "php": "^7.3 || ^8.0", + "guzzlehttp/guzzle": "^7.4.5", + "php": "^8.2", "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", - "symfony/cache": "~4.2.12 || ^4.3.8 || ^5.2 || ^6.0 || ^7.0", - "symfony/console": "^5.3 || ^6.0 || ^7.0", - "symfony/css-selector": "^4.2 || ^5.2 || ^6.0 || ^7.0", - "symfony/dom-crawler": "^5.2 || ^6.0 || ^7.0" + "symfony/cache": "^6.0 || ^7.0 || ^8.0", + "symfony/console": "^6.0 || ^7.0 || ^8.0", + "symfony/css-selector": "^6.0 || ^7.0 || ^8.0", + "symfony/dom-crawler": "^6.0 || ^7.0 || ^8.0" }, "require-dev": { - "mockery/mockery": "^1.3.3", - "phpunit/phpunit": "^9.3", - "symfony/var-dumper": "^4.2 || ^5.2 || ^6.0 || ^7.0" + "mockery/mockery": "^1.3.6", + "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0", + "symfony/var-dumper": "^6.0 || ^7.0 || ^8.0" }, "bin": [ "composer-license-checker" @@ -8656,9 +8656,9 @@ ], "support": { "issues": "https://github.com/dominikb/composer-license-checker/issues", - "source": "https://github.com/dominikb/composer-license-checker/tree/2.7.0" + "source": "https://github.com/dominikb/composer-license-checker/tree/3.0.0" }, - "time": "2025-01-26T20:46:05+00:00" + "time": "2026-04-08T20:36:51+00:00" }, { "name": "drupal/coder", @@ -9022,16 +9022,16 @@ }, { "name": "infection/infection", - "version": "0.31.9", + "version": "0.32.6", "source": { "type": "git", "url": "https://github.com/infection/infection.git", - "reference": "f9628fcd7f76eadf24726e57a81937c42458232b" + "reference": "4ed769947eaf2ecf42203027301bad2bedf037e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/infection/infection/zipball/f9628fcd7f76eadf24726e57a81937c42458232b", - "reference": "f9628fcd7f76eadf24726e57a81937c42458232b", + "url": "https://api.github.com/repos/infection/infection/zipball/4ed769947eaf2ecf42203027301bad2bedf037e5", + "reference": "4ed769947eaf2ecf42203027301bad2bedf037e5", "shasum": "" }, "require": { @@ -9048,20 +9048,22 @@ "infection/include-interceptor": "^0.2.5", "infection/mutator": "^0.4", "justinrainbow/json-schema": "^6.0", - "nikic/php-parser": "^5.3", + "nikic/php-parser": "^5.6.2", "ondram/ci-detector": "^4.1.0", "php": "^8.2", - "sanmai/di-container": "^0.1.4", + "psr/log": "^2.0 || ^3.0", + "sanmai/di-container": "^0.1.12", "sanmai/duoclock": "^0.1.0", "sanmai/later": "^0.1.7", - "sanmai/pipeline": "^7.0", - "sebastian/diff": "^4.0 || ^5.0 || ^6.0 || ^7.0", - "symfony/console": "^6.4 || ^7.0", - "symfony/filesystem": "^6.4 || ^7.0", - "symfony/finder": "^6.4 || ^7.0", - "symfony/process": "^6.4 || ^7.0", + "sanmai/pipeline": "^7.2", + "sebastian/diff": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0", + "symfony/console": "^6.4 || ^7.0 || ^8.0", + "symfony/filesystem": "^6.4 || ^7.0 || ^8.0", + "symfony/finder": "^6.4 || ^7.0 || ^8.0", + "symfony/polyfill-php85": "^1.33", + "symfony/process": "^6.4 || ^7.0 || ^8.0", "thecodingmachine/safe": "^v3.0", - "webmozart/assert": "^1.11" + "webmozart/assert": "^1.11 || ^2.0" }, "conflict": { "antecedent/patchwork": "<2.1.25", @@ -9070,18 +9072,21 @@ "require-dev": { "ext-simplexml": "*", "fidry/makefile": "^1.0", + "fig/log-test": "^1.2", + "phpbench/phpbench": "^1.4", "phpstan/extension-installer": "^1.4", "phpstan/phpstan": "^2.1", "phpstan/phpstan-phpunit": "^2.0", "phpstan/phpstan-strict-rules": "^2.0", "phpstan/phpstan-webmozart-assert": "^2.0", "phpunit/phpunit": "^11.5.27", - "rector/rector": "^2.0", - "shipmonk/dead-code-detector": "^0.12.0", + "rector/rector": "^2.2.4", + "shipmonk/dead-code-detector": "^0.14.0", "shipmonk/name-collision-detector": "^2.1", "sidz/phpstan-rules": "^0.5.1", - "symfony/yaml": "^6.4 || ^7.0", - "thecodingmachine/phpstan-safe-rule": "^1.4" + "symfony/yaml": "^6.4 || ^7.0 || ^8.0", + "thecodingmachine/phpstan-safe-rule": "^1.4", + "webmozarts/strict-phpunit": "^7.15" }, "bin": [ "bin/infection" @@ -9137,7 +9142,7 @@ ], "support": { "issues": "https://github.com/infection/infection/issues", - "source": "https://github.com/infection/infection/tree/0.31.9" + "source": "https://github.com/infection/infection/tree/0.32.6" }, "funding": [ { @@ -9149,7 +9154,7 @@ "type": "open_collective" } ], - "time": "2025-10-27T12:00:54+00:00" + "time": "2026-02-26T14:34:26+00:00" }, { "name": "infection/mutator", @@ -9328,16 +9333,16 @@ }, { "name": "justinrainbow/json-schema", - "version": "6.8.2", + "version": "6.9.0", "source": { "type": "git", "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "2c89ebb95ca9cedc9347f780333f7b25792dcb76" + "reference": "bd1bda2ebfc8bff418565941771ea8f03c557886" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/2c89ebb95ca9cedc9347f780333f7b25792dcb76", - "reference": "2c89ebb95ca9cedc9347f780333f7b25792dcb76", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/bd1bda2ebfc8bff418565941771ea8f03c557886", + "reference": "bd1bda2ebfc8bff418565941771ea8f03c557886", "shasum": "" }, "require": { @@ -9347,7 +9352,7 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "3.3.0", - "json-schema/json-schema-test-suite": "dev-main", + "json-schema/json-schema-test-suite": "^23.2", "marc-mabe/php-enum-phpstan": "^2.0", "phpspec/prophecy": "^1.19", "phpstan/phpstan": "^1.12", @@ -9397,9 +9402,9 @@ ], "support": { "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/6.8.2" + "source": "https://github.com/jsonrainbow/json-schema/tree/6.9.0" }, - "time": "2026-05-05T05:39:01+00:00" + "time": "2026-06-05T14:05:24+00:00" }, { "name": "kelunik/certificate", @@ -11775,16 +11780,16 @@ }, { "name": "sanmai/di-container", - "version": "0.1.12", + "version": "0.1.17", "source": { "type": "git", "url": "https://github.com/sanmai/di-container.git", - "reference": "8b9ad72f6ac1f9e185e5bd060dc9479cb5191d8b" + "reference": "a901c4a8778c9212ef4d66607525281af2f787bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sanmai/di-container/zipball/8b9ad72f6ac1f9e185e5bd060dc9479cb5191d8b", - "reference": "8b9ad72f6ac1f9e185e5bd060dc9479cb5191d8b", + "url": "https://api.github.com/repos/sanmai/di-container/zipball/a901c4a8778c9212ef4d66607525281af2f787bd", + "reference": "a901c4a8778c9212ef4d66607525281af2f787bd", "shasum": "" }, "require": { @@ -11842,7 +11847,7 @@ ], "support": { "issues": "https://github.com/sanmai/di-container/issues", - "source": "https://github.com/sanmai/di-container/tree/0.1.12" + "source": "https://github.com/sanmai/di-container/tree/0.1.17" }, "funding": [ { @@ -11850,7 +11855,7 @@ "type": "github" } ], - "time": "2026-01-27T08:25:46+00:00" + "time": "2026-06-01T08:52:14+00:00" }, { "name": "sanmai/duoclock", @@ -13761,16 +13766,16 @@ }, { "name": "symfony/polyfill-php84", - "version": "v1.38.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "a0e0aca0368801ec79f8791dea9a7c12af527c93" + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/a0e0aca0368801ec79f8791dea9a7c12af527c93", - "reference": "a0e0aca0368801ec79f8791dea9a7c12af527c93", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", "shasum": "" }, "require": { @@ -13817,7 +13822,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" }, "funding": [ { @@ -13837,7 +13842,7 @@ "type": "tidelift" } ], - "time": "2026-05-25T12:12:52+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "theseer/tokenizer", @@ -13971,23 +13976,23 @@ }, { "name": "webmozart/assert", - "version": "1.12.1", + "version": "2.4.0", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" + "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155", + "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155", "shasum": "" }, "require": { "ext-ctype": "*", "ext-date": "*", "ext-filter": "*", - "php": "^7.2 || ^8.0" + "php": "^8.2" }, "suggest": { "ext-intl": "", @@ -13996,8 +14001,12 @@ }, "type": "library", "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, "branch-alias": { - "dev-master": "1.10-dev" + "dev-master": "2.0-dev", + "dev-feature/2-0": "2.0-dev" } }, "autoload": { @@ -14013,6 +14022,10 @@ { "name": "Bernhard Schussek", "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" } ], "description": "Assertions to validate method input/output with nice error messages.", @@ -14023,9 +14036,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.12.1" + "source": "https://github.com/webmozarts/assert/tree/2.4.0" }, - "time": "2025-10-29T15:56:20+00:00" + "time": "2026-05-20T13:07:01+00:00" } ], "aliases": [ From 480bbe9f7346cbbe33d880665aa492a3cbd52f0a Mon Sep 17 00:00:00 2001 From: Matthew Grasmick Date: Wed, 10 Jun 2026 23:38:46 -0400 Subject: [PATCH 04/17] Remove unused direct dependency laminas/laminas-validator No Laminas code is referenced anywhere in src/, tests/, or bin/. The package remains installed transitively via ltd-beget/dns-zone-configurator. Co-Authored-By: Claude Fable 5 --- composer.json | 1 - composer.lock | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/composer.json b/composer.json index c052b660..9eb7b99b 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,6 @@ "grasmash/expander": "^3.0.1", "guzzlehttp/guzzle": "^7.4", "http-interop/http-factory-guzzle": "^1.0", - "laminas/laminas-validator": "^2.20.0", "league/csv": "^9.8", "loophp/phposinfo": "^1.7.2", "ltd-beget/dns-zone-configurator": "^1.4.0", diff --git a/composer.lock b/composer.lock index c2c7077b..212b4167 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4632ff793bb98879116b188ff04b3776", + "content-hash": "ef40c9a910dc4704d29c344e291ad744", "packages": [ { "name": "acquia/drupal-environment-detector", From 89fd44c7b4ab56f330c98c390d360f37ddc22dfb Mon Sep 17 00:00:00 2001 From: Matthew Grasmick Date: Wed, 10 Jun 2026 23:41:59 -0400 Subject: [PATCH 05/17] Document shell completion support in README Symfony Console ships completion for bash/zsh/fish out of the box, but it was undocumented. Co-Authored-By: Claude Fable 5 --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 82c38c0f..a192edfe 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,16 @@ Acquia CLI is not a local development environment. If you are looking for an int Install instructions and official documentation are available at https://docs.acquia.com/acquia-cli/install/ +### Shell completion + +Acquia CLI supports tab completion for bash, zsh, and fish. To enable it, run +`acli completion --help` and follow the installation instructions for your +shell. For example, for bash: + +```shell +acli completion bash > /etc/bash_completion.d/acli +``` + ## Contribution See [CONTRIBUTING.md](CONTRIBUTING.md) for instructions on building, testing, and contributing to Acquia CLI. From 05ed02f35d2e575d6b4e146e1489950dcb3f2785 Mon Sep 17 00:00:00 2001 From: Matthew Grasmick Date: Wed, 10 Jun 2026 23:54:00 -0400 Subject: [PATCH 06/17] Improve api command generation performance - Hoist getSkippedApiCommands() out of the per-endpoint loop - Replace O(n^2) namespace visibility scan in generateApiListCommands() with a single-pass keyed map Co-Authored-By: Claude Fable 5 --- src/Command/Api/ApiCommandHelper.php | 83 ++++++++----------- .../src/Commands/Api/ApiCommandHelperTest.php | 57 +++++++++++++ 2 files changed, 93 insertions(+), 47 deletions(-) diff --git a/src/Command/Api/ApiCommandHelper.php b/src/Command/Api/ApiCommandHelper.php index aaa2236f..04594ed6 100644 --- a/src/Command/Api/ApiCommandHelper.php +++ b/src/Command/Api/ApiCommandHelper.php @@ -6,7 +6,7 @@ use Acquia\Cli\CommandFactoryInterface; use Acquia\Cli\Exception\AcquiaCliException; -use Symfony\Component\Cache\Adapter\NullAdapter; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; use Symfony\Component\Cache\Adapter\PhpArrayAdapter; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputDefinition; @@ -340,7 +340,9 @@ private function isApiSpecChecksumCacheValid(\Symfony\Component\Cache\CacheItem private function getCloudApiSpec(string $specFilePath): array { $cacheKey = basename($specFilePath); - $cache = new PhpArrayAdapter(__DIR__ . '/../../../var/cache/' . $cacheKey . '.cache', new NullAdapter()); + // Fall back to a filesystem cache so the parsed spec is still cached + // between invocations when the warmed PHP cache file is absent. + $cache = new PhpArrayAdapter(__DIR__ . '/../../../var/cache/' . $cacheKey . '.cache', new FilesystemAdapter()); $cacheItemChecksum = $cache->getItem($cacheKey . '.checksum'); $cacheItemSpec = $cache->getItem($cacheKey); @@ -363,6 +365,13 @@ private function getCloudApiSpec(string $specFilePath): array $this->logger->debug("Rebuilding caches..."); $spec = json_decode(file_get_contents($specFilePath), true); + // Populate the fallback cache so the parse result survives even if + // the warmed PHP cache file is later deleted. + $cacheItemSpec->set($spec); + $cache->save($cacheItemSpec); + $cacheItemChecksum->set($checksum); + $cache->save($cacheItemChecksum); + $cache->warmUp([ $cacheKey => $spec, $cacheKey . '.checksum' => $checksum, @@ -377,13 +386,14 @@ private function getCloudApiSpec(string $specFilePath): array private function generateApiCommandsFromSpec(array $acquiaCloudSpec, string $commandPrefix, CommandFactoryInterface $commandFactory): array { $apiCommands = []; + $skippedApiCommands = $this->getSkippedApiCommands(); foreach ($acquiaCloudSpec['paths'] as $path => $endpoint) { foreach ($endpoint as $method => $schema) { if (!array_key_exists('x-cli-name', $schema)) { continue; } - if (in_array($schema['x-cli-name'], $this->getSkippedApiCommands(), true)) { + if (in_array($schema['x-cli-name'], $skippedApiCommands, true)) { continue; } @@ -555,62 +565,41 @@ public static function restoreRenamedParameter(string $propKey): string */ private function generateApiListCommands(array $apiCommands, string $commandPrefix, CommandFactoryInterface $commandFactory): array { - $apiListCommands = []; + // List commands (api:{namespace}) are only registered when at least one + // sub-command under that namespace exists and is visible. If every + // sub-command is hidden (deprecated/pre-release), the namespace list is + // omitted. Index namespace visibility in a single pass to avoid + // re-scanning the full command list for every command. + $namespaceHasVisibleCommand = []; foreach ($apiCommands as $apiCommand) { $commandNameParts = explode(':', $apiCommand->getName()); if (count($commandNameParts) < 3) { continue; } $namespace = $commandNameParts[1]; - $name = $commandPrefix . ':' . $namespace; - $hasVisibleCommand = $this->namespaceHasVisibleCommand($apiCommands, $namespace); - if (!array_key_exists($name, $apiListCommands) && $hasVisibleCommand) { - /** @var \Acquia\Cli\Command\Acsf\AcsfListCommand|\Acquia\Cli\Command\Api\ApiListCommand $command */ - $command = $commandFactory->createListCommand(); - $command->setName($name); - $command->setNamespace($name); - $command->setAliases([]); - $command->setDescription("List all API commands for the $namespace resource"); - $apiListCommands[$name] = $command; - } - } - return $apiListCommands; - } - - /** - * Whether any API command in the given namespace is visible (not hidden). - * - * List commands (api:{namespace}) are only registered when at least one sub-command - * under that namespace exists and is visible. If every sub-command is hidden - * (deprecated/pre-release), the namespace list is omitted. - * - * @param ApiBaseCommand[] $apiCommands - */ - private function namespaceHasVisibleCommand(array $apiCommands, string $namespace): bool - { - $commandsInNamespace = []; - foreach ($apiCommands as $apiCommand) { - $commandNameParts = explode(':', $apiCommand->getName()); - if (count($commandNameParts) < 3) { - continue; + if (!array_key_exists($namespace, $namespaceHasVisibleCommand)) { + $namespaceHasVisibleCommand[$namespace] = false; } - if ($commandNameParts[1] !== $namespace) { - continue; + if (!$apiCommand->isHidden()) { + $namespaceHasVisibleCommand[$namespace] = true; } - $commandsInNamespace[] = $apiCommand; } - if ($commandsInNamespace === []) { - return false; - } - - foreach ($commandsInNamespace as $command) { - if (!$command->isHidden()) { - return true; + $apiListCommands = []; + foreach ($namespaceHasVisibleCommand as $namespace => $hasVisibleCommand) { + if (!$hasVisibleCommand) { + continue; } + $name = $commandPrefix . ':' . $namespace; + /** @var \Acquia\Cli\Command\Acsf\AcsfListCommand|\Acquia\Cli\Command\Api\ApiListCommand $command */ + $command = $commandFactory->createListCommand(); + $command->setName($name); + $command->setNamespace($name); + $command->setAliases([]); + $command->setDescription("List all API commands for the $namespace resource"); + $apiListCommands[$name] = $command; } - - return false; + return $apiListCommands; } /** diff --git a/tests/phpunit/src/Commands/Api/ApiCommandHelperTest.php b/tests/phpunit/src/Commands/Api/ApiCommandHelperTest.php index 1dd469e4..f939a00a 100644 --- a/tests/phpunit/src/Commands/Api/ApiCommandHelperTest.php +++ b/tests/phpunit/src/Commands/Api/ApiCommandHelperTest.php @@ -9,7 +9,11 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Tests\CommandTestBase; use ReflectionMethod; +use Symfony\Component\Cache\Adapter\PhpArrayAdapter; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Logger\ConsoleLogger; +use Symfony\Component\Console\Output\BufferedOutput; +use Symfony\Component\Console\Output\OutputInterface; /** * Tests for ApiCommandHelper::generateApiListCommands (via reflection). @@ -110,6 +114,59 @@ public function testNamespaceWithAllHiddenCommandsDoesNotGetListCommand(): void $this->assertArrayNotHasKey('api:baz', $listCommands); } + /** + * The parsed spec must be served from the fallback cache when the warmed + * PHP cache file is absent, instead of being re-parsed on every invocation. + */ + public function testGetCloudApiSpecUsesFallbackCacheWhenWarmedCacheFileIsMissing(): void + { + $specFilePath = tempnam(sys_get_temp_dir(), 'acli_spec_test_'); + file_put_contents($specFilePath, json_encode([ + 'components' => [], + 'paths' => [], + ], JSON_THROW_ON_ERROR)); + $warmedCacheFilePath = dirname(__DIR__, 5) . '/var/cache/' . basename($specFilePath) . '.cache'; + putenv('ACQUIA_CLI_USE_CLOUD_API_SPEC_CACHE=1'); + + try { + $output = new BufferedOutput(OutputInterface::VERBOSITY_DEBUG); + $helper = new ApiCommandHelper(new ConsoleLogger($output)); + $method = new ReflectionMethod(ApiCommandHelper::class, 'getCloudApiSpec'); + + // First call parses the spec file and warms the caches. + $firstSpec = $method->invoke($helper, $specFilePath); + $this->assertStringContainsString('Rebuilding caches', $output->fetch()); + + // Even with the warmed cache file gone, the fallback cache must + // serve the parsed spec rather than re-parsing the spec file. + unlink($warmedCacheFilePath); + $this->resetPhpArrayAdapterStaticCache(); + $secondSpec = $method->invoke($helper, $specFilePath); + $this->assertSame($firstSpec, $secondSpec); + $this->assertStringNotContainsString('Rebuilding caches', $output->fetch()); + } finally { + putenv('ACQUIA_CLI_USE_CLOUD_API_SPEC_CACHE'); + if (file_exists($specFilePath)) { + unlink($specFilePath); + } + if (file_exists($warmedCacheFilePath)) { + unlink($warmedCacheFilePath); + } + } + } + + /** + * Forget warmed cache files in PhpArrayAdapter's static in-process cache. + * + * Simulates a fresh CLI invocation, in which a deleted warmed cache file + * would no longer be readable. + */ + private function resetPhpArrayAdapterStaticCache(): void + { + $property = new \ReflectionProperty(PhpArrayAdapter::class, 'valuesCache'); + $property->setValue(null, []); + } + /** * Calls private or protected method of ApiCommandHelper class via reflection. * From 87a8e57f3d3dc947fca33e42e1fce902be83c8b8 Mon Sep 17 00:00:00 2001 From: Matthew Grasmick Date: Wed, 10 Jun 2026 23:54:03 -0400 Subject: [PATCH 07/17] Add GitHub issue form templates for bug reports and feature requests Co-Authored-By: Claude Fable 5 --- .github/ISSUE_TEMPLATE/bug_report.yml | 66 ++++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 11 ++++ .github/ISSUE_TEMPLATE/feature_request.yml | 29 ++++++++++ 3 files changed, 106 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..783ff391 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,66 @@ +name: Bug report +description: Report a problem with Acquia CLI. +labels: [bug] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug! Please fill out the fields below to help us reproduce and fix the issue. + + If you have a question or need help from Acquia, visit the [Acquia Support Portal](https://acquia.my.site.com/s/) or the [discussions section](https://github.com/acquia/cli/discussions) instead. + - type: input + id: acli-version + attributes: + label: Acquia CLI version + description: Output of `acli --version`. + placeholder: Acquia CLI 2.x.x + validations: + required: true + - type: input + id: php-version + attributes: + label: PHP version + description: Output of `php --version`. + placeholder: PHP 8.3.x + validations: + required: true + - type: input + id: os + attributes: + label: Operating system + description: Your OS and version, and whether you're running in a special environment (Acquia Cloud IDE, WSL, Docker, etc.). + placeholder: macOS 15.5, Ubuntu 24.04, Windows 11 (WSL2), Acquia Cloud IDE + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: The exact commands you ran and any relevant configuration. + placeholder: | + 1. Run `acli ...` + 2. ... + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What you expected to happen. + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + description: What actually happened, including any error messages. + validations: + required: true + - type: textarea + id: verbose-output + attributes: + label: Verbose output + description: Re-run the failing command with maximum verbosity (`acli -vvv`) and paste the output. Remove any sensitive information such as API keys or tokens. + render: shell + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..19bf0fe2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: true +contact_links: + - name: Security vulnerability report + url: https://www.acquia.com/why-acquia/acquia-security + about: Please do not report security vulnerabilities in public issues. Report them privately via Acquia's security program. + - name: Acquia Support Portal + url: https://acquia.my.site.com/s/ + about: Get support from Acquia for your subscription and products. + - name: GitHub Discussions + url: https://github.com/acquia/cli/discussions + about: Ask questions and discuss ideas with other Acquia CLI users. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..88458584 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,29 @@ +name: Feature request +description: Suggest a new feature or improvement for Acquia CLI. +labels: [enhancement] +body: + - type: markdown + attributes: + value: | + Thanks for suggesting an improvement! If you'd like to discuss an idea with other Acquia CLI users first, consider the [discussions section](https://github.com/acquia/cli/discussions). + - type: textarea + id: problem + attributes: + label: Problem statement + description: What problem are you trying to solve? What is currently difficult or impossible to do? + validations: + required: true + - type: textarea + id: proposed-solution + attributes: + label: Proposed solution + description: How would you like Acquia CLI to solve this? Include example commands or output if helpful. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Any workarounds or alternative solutions you've considered. + validations: + required: false From f6e48d93600a4c8a8f134406a32b3b5f0106c45e Mon Sep 17 00:00:00 2001 From: Matthew Grasmick Date: Wed, 10 Jun 2026 23:54:15 -0400 Subject: [PATCH 08/17] Harden security across telemetry, SSH, and local file handling - Redact key/secret/password/token values before sending command arguments and options to Amplitude telemetry, and extend the Bugsnag context redaction beyond --password to --key and --secret - Use StrictHostKeyChecking=accept-new instead of =no for all SSH, rsync, and git operations so changed host keys fail instead of being silently accepted (TOFU; OpenSSH 7.6+) - Pass browser launch URIs as process arguments instead of a shell string, eliminating shell injection via crafted URIs - chmod credential files written by JsonDataStore to 0600 - Enforce 0600 on generated SSH private keys and 0700 on ~/.ssh - Replace error suppression in posix_isatty and aliases archive extraction with explicit error handling and actionable messages - Remove unused CommandBase::$cloudApplication property; use strict array comparison in CommandBase Co-Authored-By: Claude Fable 5 --- src/Command/CommandBase.php | 10 ++-- src/Command/Pull/PullCommandBase.php | 2 +- src/Command/Push/PushDatabaseCommand.php | 2 +- src/Command/Remote/AliasesDownloadCommand.php | 23 ++++++-- src/Command/Ssh/SshKeyCommandBase.php | 10 ++++ src/DataStore/JsonDataStore.php | 2 + src/Helpers/LocalMachineHelper.php | 40 ++++++++++++- src/Helpers/SshHelper.php | 2 +- src/Helpers/TelemetryHelper.php | 43 +++++++++++++- .../src/Commands/Pull/PullCodeCommandTest.php | 2 +- .../src/Commands/Pull/PullCommandTestBase.php | 2 +- .../Commands/Pull/PullDatabaseCommandTest.php | 5 +- .../Commands/Push/PushDatabaseCommandTest.php | 6 +- .../Commands/Push/PushFilesCommandTest.php | 4 +- .../Remote/AliasesDownloadCommandTest.php | 3 +- .../src/Commands/Remote/DrushCommandTest.php | 2 +- .../src/Commands/Remote/SshCommandTest.php | 2 +- .../Commands/Self/TelemetryCommandTest.php | 38 ++++++++++++ .../Commands/Ssh/SshKeyCreateCommandTest.php | 45 ++++++++++++++ .../src/DataStore/JsonDataStoreTest.php | 58 +++++++++++++++++++ .../src/Misc/LocalMachineHelperTest.php | 42 ++++++++++++++ .../phpunit/src/Misc/TelemetryHelperTest.php | 54 +++++++++++++++++ 22 files changed, 367 insertions(+), 30 deletions(-) create mode 100644 tests/phpunit/src/DataStore/JsonDataStoreTest.php diff --git a/src/Command/CommandBase.php b/src/Command/CommandBase.php index 8c217f6d..00c912a2 100644 --- a/src/Command/CommandBase.php +++ b/src/Command/CommandBase.php @@ -99,8 +99,6 @@ abstract class CommandBase extends Command implements LoggerAwareInterface protected FormatterHelper $formatter; - private ApplicationResponse $cloudApplication; - protected string $siteId = ""; protected string $dir; @@ -301,7 +299,7 @@ public function run(InputInterface $input, OutputInterface $output): int $exitCode === 0 && in_array($input->getFirstArgument(), [ 'self-update', 'update', - ]) + ], true) ) { // Exit immediately to avoid loading additional classes breaking updates. // @see https://github.com/acquia/cli/issues/218 @@ -309,9 +307,9 @@ public function run(InputInterface $input, OutputInterface $output): int } $eventProperties = [ 'app_version' => $this->getApplication()->getVersion(), - 'arguments' => $input->getArguments(), + 'arguments' => TelemetryHelper::redactSensitiveData($input->getArguments()), 'exit_code' => $exitCode, - 'options' => $input->getOptions(), + 'options' => TelemetryHelper::redactSensitiveData($input->getOptions()), 'os_name' => OsInfo::os(), 'os_version' => OsInfo::version(), 'platform' => OsInfo::family(), @@ -558,7 +556,7 @@ protected function rsyncFiles(string $sourceDir, string $destinationDir, ?callab // -h output numbers in a human-readable format. // -e specify the remote shell to use. '-avPhze', - 'ssh -o StrictHostKeyChecking=no', + 'ssh -o StrictHostKeyChecking=accept-new', $sourceDir . '/', $destinationDir, ]; diff --git a/src/Command/Pull/PullCommandBase.php b/src/Command/Pull/PullCommandBase.php index 0e6fd4e3..a854e6d2 100644 --- a/src/Command/Pull/PullCommandBase.php +++ b/src/Command/Pull/PullCommandBase.php @@ -582,7 +582,7 @@ private function cloneFromCloud(EnvironmentResponse $chosenEnvironment, Closure $chosenEnvironment->vcs->url, $this->dir, ]; - $process = $this->localMachineHelper->execute($command, $outputCallback, null, ($this->output->getVerbosity() > OutputInterface::VERBOSITY_NORMAL), null, ['GIT_SSH_COMMAND' => 'ssh -o StrictHostKeyChecking=no']); + $process = $this->localMachineHelper->execute($command, $outputCallback, null, ($this->output->getVerbosity() > OutputInterface::VERBOSITY_NORMAL), null, ['GIT_SSH_COMMAND' => 'ssh -o StrictHostKeyChecking=accept-new']); $this->checkoutBranchFromEnv($chosenEnvironment, $outputCallback); if (!$process->isSuccessful()) { throw new AcquiaCliException('Failed to clone repository from the Cloud Platform: {message}', ['message' => $process->getErrorOutput()]); diff --git a/src/Command/Push/PushDatabaseCommand.php b/src/Command/Push/PushDatabaseCommand.php index 8104a147..87237abe 100644 --- a/src/Command/Push/PushDatabaseCommand.php +++ b/src/Command/Push/PushDatabaseCommand.php @@ -80,7 +80,7 @@ private function uploadDatabaseDump( $command = [ 'rsync', '-tDvPhe', - 'ssh -o StrictHostKeyChecking=no', + 'ssh -o StrictHostKeyChecking=accept-new', $localFilepath, $environment->sshUrl . ':' . $remoteFilepath, ]; diff --git a/src/Command/Remote/AliasesDownloadCommand.php b/src/Command/Remote/AliasesDownloadCommand.php index 771542ba..ad9e1739 100644 --- a/src/Command/Remote/AliasesDownloadCommand.php +++ b/src/Command/Remote/AliasesDownloadCommand.php @@ -140,11 +140,26 @@ protected function downloadDrush9Aliases(InputInterface $input, string $aliasVer $drushFiles[] = $baseDir . '/' . $file->getFileName(); } } + // Convert warnings on permissions errors to exceptions so they can + // be caught and reported instead of being silently ignored. + set_error_handler(static function (int $errno, string $errstr): bool { + throw new \RuntimeException($errstr); + }); try { - // Throws warnings on permissions errors. - @$archive->extractTo($drushAliasesDir, $drushFiles, true); - } catch (\Exception) { - throw new AcquiaCliException('Could not extract aliases to {destination}', ['destination' => $drushAliasesDir]); + $extracted = $archive->extractTo($drushAliasesDir, $drushFiles, true); + } catch (\Exception $exception) { + throw new AcquiaCliException('Failed to extract aliases archive at {filepath}: {message}', [ + 'filepath' => $drushArchiveTempFilepath, + 'message' => $exception->getMessage(), + ]); + } finally { + restore_error_handler(); + } + if (!$extracted) { + throw new AcquiaCliException('Failed to extract aliases archive at {filepath}: could not extract to {destination}', [ + 'destination' => $drushAliasesDir, + 'filepath' => $drushArchiveTempFilepath, + ]); } } diff --git a/src/Command/Ssh/SshKeyCommandBase.php b/src/Command/Ssh/SshKeyCommandBase.php index 9680e8f9..c7132cb8 100644 --- a/src/Command/Ssh/SshKeyCommandBase.php +++ b/src/Command/Ssh/SshKeyCommandBase.php @@ -265,6 +265,16 @@ private function doCreateSshKey(string $filename, string $password): string throw new AcquiaCliException($process->getOutput() . $process->getErrorOutput()); } + // The ssh-keygen utility sets restrictive permissions itself, but + // enforce them defensively in case of a permissive umask or + // non-standard ssh-keygen implementation. + if (file_exists($filepath)) { + chmod($filepath, 0600); + } + if (file_exists($this->sshDir)) { + chmod($this->sshDir, 0700); + } + return $filepath; } diff --git a/src/DataStore/JsonDataStore.php b/src/DataStore/JsonDataStore.php index 659fae00..9347df15 100644 --- a/src/DataStore/JsonDataStore.php +++ b/src/DataStore/JsonDataStore.php @@ -34,6 +34,8 @@ public function __construct(string $path, ?ConfigurationInterface $configDefinit public function dump(): void { $this->fileSystem->dumpFile($this->filepath, json_encode($this->data->export(), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); + // Restrict access since this file may contain credentials. + $this->fileSystem->chmod($this->filepath, 0600); } protected function cleanLegacyConfig(array &$array): bool diff --git a/src/Helpers/LocalMachineHelper.php b/src/Helpers/LocalMachineHelper.php index 8a655708..d28085cd 100644 --- a/src/Helpers/LocalMachineHelper.php +++ b/src/Helpers/LocalMachineHelper.php @@ -110,7 +110,7 @@ public function executeFromCmd(string $cmd, ?callable $callback = null, ?string */ private function configureProcess(Process $process, ?string $cwd = null, ?bool $printOutput = true, ?float $timeout = null, ?array $env = null, bool $stdin = true): Process { - if (function_exists('posix_isatty') && $stdin && !@posix_isatty(STDIN)) { + if (function_exists('posix_isatty') && $stdin && !$this->isTtyStream(STDIN)) { $process->setInput(STDIN); } if ($cwd) { @@ -125,6 +125,29 @@ private function configureProcess(Process $process, ?string $cwd = null, ?bool $ return $process; } + /** + * Determines whether a stream refers to an interactive terminal. + * + * posix_isatty() emits a warning (and returns FALSE) for streams that + * cannot be mapped to a file descriptor, e.g. in-memory streams. Handle + * that case explicitly instead of suppressing errors. + * + * @param resource|int $fileDescriptor + */ + private function isTtyStream(mixed $fileDescriptor): bool + { + set_error_handler(static function (): bool { + // Swallow the warning; posix_isatty() returns FALSE in this + // case, correctly reporting the stream as not a TTY. + return true; + }); + try { + return posix_isatty($fileDescriptor); + } finally { + restore_error_handler(); + } + } + private function executeProcess(Process $process, ?callable $callback = null, ?bool $printOutput = true, ?array $env = null): Process { if ($callback === null && $printOutput !== false) { @@ -400,7 +423,20 @@ public function startBrowser(?string $uri = null, ?string $browser = null): bool } if ($browser) { $this->io->info("Opening $uri"); - $this->executeFromCmd("$browser $uri"); + // Split the browser command on whitespace to support commands + // with arguments (e.g. "open -a Firefox") while passing the URI + // as a single argument so it is never shell-interpreted. + $cmd = array_values(array_filter(explode(' ', $browser), static function (string $part): bool { + return $part !== ''; + })); + if ($cmd[0] === 'start') { + // On Windows, `start` is a cmd.exe built-in rather than an + // executable, and it treats the first quoted argument as a + // window title, so pass an empty title before the URI. + $cmd = ['cmd', '/c', 'start', '']; + } + $cmd[] = $uri; + $this->execute($cmd, null, null, false); return true; } diff --git a/src/Helpers/SshHelper.php b/src/Helpers/SshHelper.php index 310eddfc..ec845964 100644 --- a/src/Helpers/SshHelper.php +++ b/src/Helpers/SshHelper.php @@ -108,7 +108,7 @@ private function getConnectionArgs(string $url): array 'ssh', $url, '-t', - '-o StrictHostKeyChecking=no', + '-o StrictHostKeyChecking=accept-new', '-o AddressFamily inet', '-o LogLevel=ERROR', ]; diff --git a/src/Helpers/TelemetryHelper.php b/src/Helpers/TelemetryHelper.php index 7c6caf9c..5fd96875 100644 --- a/src/Helpers/TelemetryHelper.php +++ b/src/Helpers/TelemetryHelper.php @@ -56,6 +56,45 @@ public static function isBuildDateOlderThanMonths(?string $buildDate, int $month return ($now - $buildTimestamp) > $interval; } + /** + * Redact values of sensitive options and arguments in telemetry data. + * + * @param array $data + * An array of option or argument values keyed by name. + * @return array + * The array with sensitive values redacted. + */ + public static function redactSensitiveData(array $data): array + { + $sensitiveNames = [ + 'api-key', + 'api-secret', + 'key', + 'password', + 'secret', + 'token', + ]; + foreach ($data as $name => $value) { + if ($value !== null && in_array($name, $sensitiveNames, true)) { + $data[$name] = 'REDACTED'; + } + } + return $data; + } + + /** + * Redact sensitive command-line parameters from a Bugsnag context string. + */ + public static function redactSensitiveContext(string $context): string + { + foreach (['--password', '--key', '--secret'] as $sensitiveOption) { + if (str_contains($context, $sensitiveOption)) { + $context = substr($context, 0, strpos($context, $sensitiveOption) + strlen($sensitiveOption)) . 'REDACTED'; + } + } + return $context; + } + public function initializeBugsnag(): void { if (empty($this->bugSnagKey)) { @@ -103,9 +142,7 @@ public function initializeBugsnag(): void $context = substr($context, strpos($context, 'acli ') + 5); } // Strip sensitive parameters from context. - if (str_contains($context, "--password")) { - $context = substr($context, 0, strpos($context, "--password") + 10) . 'REDACTED'; - } + $context = self::redactSensitiveContext($context); $report->setContext($context); return true; }); diff --git a/tests/phpunit/src/Commands/Pull/PullCodeCommandTest.php b/tests/phpunit/src/Commands/Pull/PullCodeCommandTest.php index 2bb2af62..4e828769 100644 --- a/tests/phpunit/src/Commands/Pull/PullCodeCommandTest.php +++ b/tests/phpunit/src/Commands/Pull/PullCodeCommandTest.php @@ -300,7 +300,7 @@ protected function mockExecuteGitClone( $environmentsResponse->vcs->url, $dir, ]; - $localMachineHelper->execute($command, Argument::type('callable'), null, true, null, ['GIT_SSH_COMMAND' => 'ssh -o StrictHostKeyChecking=no']) + $localMachineHelper->execute($command, Argument::type('callable'), null, true, null, ['GIT_SSH_COMMAND' => 'ssh -o StrictHostKeyChecking=accept-new']) ->willReturn($process->reveal()) ->shouldBeCalled(); } diff --git a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php index 2c1c5a60..11283b1d 100644 --- a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php +++ b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php @@ -232,7 +232,7 @@ protected function mockExecuteRsync( $command = [ 'rsync', '-avPhze', - 'ssh -o StrictHostKeyChecking=no', + 'ssh -o StrictHostKeyChecking=accept-new', $environment->ssh_url . ':' . $sourceDir, $destinationDir, ]; diff --git a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php index 2aff8585..f61e3b80 100644 --- a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php +++ b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php @@ -395,8 +395,9 @@ public function testDownloadProgressDisplay(): void PullCommandBase::displayDownloadProgress(100, 0, $progress, $output); $this->assertStringContainsString('0/100 [💧---------------------------] 0%', $output->fetch()); - // Need to sleep to prevent the default redraw frequency from skipping display. - sleep(1); + // Disable time-based redraw throttling so subsequent progress updates + // are always displayed, deterministically. + $progress->minSecondsBetweenRedraws(0); PullCommandBase::displayDownloadProgress(100, 50, $progress, $output); $this->assertStringContainsString('50/100 [==============💧-------------] 50%', $output->fetch()); diff --git a/tests/phpunit/src/Commands/Push/PushDatabaseCommandTest.php b/tests/phpunit/src/Commands/Push/PushDatabaseCommandTest.php index 9562f119..1c165058 100644 --- a/tests/phpunit/src/Commands/Push/PushDatabaseCommandTest.php +++ b/tests/phpunit/src/Commands/Push/PushDatabaseCommandTest.php @@ -143,7 +143,7 @@ protected function mockUploadDatabaseDump( $command = [ 'rsync', '-tDvPhe', - 'ssh -o StrictHostKeyChecking=no', + 'ssh -o StrictHostKeyChecking=accept-new', sys_get_temp_dir() . '/acli-mysql-dump-drupal.sql.gz', 'profserv2.01dev@profserv201dev.ssh.enterprise-g1.acquia-sites.com:/mnt/tmp/profserv2.01dev/acli-mysql-dump-drupal.sql.gz', ]; @@ -180,7 +180,7 @@ protected function mockGetAcsfSitesLMH(ObjectProphecy $localMachineHelper): void 0 => 'ssh', 1 => 'profserv2.01dev@profserv201dev.ssh.enterprise-g1.acquia-sites.com', 2 => '-t', - 3 => '-o StrictHostKeyChecking=no', + 3 => '-o StrictHostKeyChecking=accept-new', 4 => '-o AddressFamily inet', 5 => '-o LogLevel=ERROR', 6 => 'cat', @@ -197,7 +197,7 @@ private function mockImportDatabaseDumpOnRemote(ObjectProphecy|LocalMachineHelpe 0 => 'ssh', 1 => 'profserv2.01dev@profserv201dev.ssh.enterprise-g1.acquia-sites.com', 2 => '-t', - 3 => '-o StrictHostKeyChecking=no', + 3 => '-o StrictHostKeyChecking=accept-new', 4 => '-o AddressFamily inet', 5 => '-o LogLevel=ERROR', 6 => 'pv /mnt/tmp/profserv2.01dev/acli-mysql-dump-drupal.sql.gz --bytes --rate | gunzip | MYSQL_PWD=password mysql --host=fsdb-74.enterprise-g1.hosting.acquia.com.enterprise-g1.hosting.acquia.com --user=s164 profserv2db14390', diff --git a/tests/phpunit/src/Commands/Push/PushFilesCommandTest.php b/tests/phpunit/src/Commands/Push/PushFilesCommandTest.php index 07dae5af..e2572672 100644 --- a/tests/phpunit/src/Commands/Push/PushFilesCommandTest.php +++ b/tests/phpunit/src/Commands/Push/PushFilesCommandTest.php @@ -144,7 +144,7 @@ protected function mockExecuteCloudRsync( $command = [ 'rsync', '-avPhze', - 'ssh -o StrictHostKeyChecking=no', + 'ssh -o StrictHostKeyChecking=accept-new', $this->projectDir . '/docroot/sites/default/files/', $environment->ssh_url . ':/mnt/files/' . $sitegroup . '.' . $environment->name . '/sites/default/files', ]; @@ -163,7 +163,7 @@ protected function mockExecuteAcsfRsync( $command = [ 'rsync', '-avPhze', - 'ssh -o StrictHostKeyChecking=no', + 'ssh -o StrictHostKeyChecking=accept-new', $this->projectDir . '/docroot/sites/' . $site . '/files/', 'profserv2.01dev@profserv201dev.ssh.enterprise-g1.acquia-sites.com:/mnt/files/profserv2.01dev/sites/g/files/' . $site . '/files', ]; diff --git a/tests/phpunit/src/Commands/Remote/AliasesDownloadCommandTest.php b/tests/phpunit/src/Commands/Remote/AliasesDownloadCommandTest.php index d3c4780a..e3f4b4b8 100644 --- a/tests/phpunit/src/Commands/Remote/AliasesDownloadCommandTest.php +++ b/tests/phpunit/src/Commands/Remote/AliasesDownloadCommandTest.php @@ -107,12 +107,13 @@ public function testRemoteAliasesDownloadFailed(): void $this->clientProphecy->stream('get', '/account/drush-aliases/download') ->willReturn($stream); + $drushArchiveFilepath = $this->command->getDrushArchiveTempFilepath(); $destinationDir = Path::join($this->acliRepoRoot, 'drush'); $sitesDir = Path::join($destinationDir, 'sites'); mkdir($sitesDir, 0777, true); chmod($sitesDir, 000); $this->expectException(AcquiaCliException::class); - $this->expectExceptionMessage("Could not extract aliases to $destinationDir"); + $this->expectExceptionMessage("Failed to extract aliases archive at $drushArchiveFilepath:"); $this->executeCommand([ '--all' => true, '--destination-dir' => $destinationDir, diff --git a/tests/phpunit/src/Commands/Remote/DrushCommandTest.php b/tests/phpunit/src/Commands/Remote/DrushCommandTest.php index 4eba72b9..3ddaf3e2 100644 --- a/tests/phpunit/src/Commands/Remote/DrushCommandTest.php +++ b/tests/phpunit/src/Commands/Remote/DrushCommandTest.php @@ -51,7 +51,7 @@ public function testRemoteDrushCommand(array $args): void 'ssh', 'site.dev@sitedev.ssh.hosted.acquia-sites.com', '-t', - '-o StrictHostKeyChecking=no', + '-o StrictHostKeyChecking=accept-new', '-o AddressFamily inet', '-o LogLevel=ERROR', 'cd /var/www/html/site.dev/docroot; ', diff --git a/tests/phpunit/src/Commands/Remote/SshCommandTest.php b/tests/phpunit/src/Commands/Remote/SshCommandTest.php index e0014354..d6d5f370 100644 --- a/tests/phpunit/src/Commands/Remote/SshCommandTest.php +++ b/tests/phpunit/src/Commands/Remote/SshCommandTest.php @@ -34,7 +34,7 @@ public function testRemoteAliasesDownloadCommand(): void 'ssh', 'site.dev@sitedev.ssh.hosted.acquia-sites.com', '-t', - '-o StrictHostKeyChecking=no', + '-o StrictHostKeyChecking=accept-new', '-o AddressFamily inet', '-o LogLevel=ERROR', 'cd /var/www/html/devcloud2.dev; exec $SHELL -l', diff --git a/tests/phpunit/src/Commands/Self/TelemetryCommandTest.php b/tests/phpunit/src/Commands/Self/TelemetryCommandTest.php index 2da3bd3b..97c418e1 100644 --- a/tests/phpunit/src/Commands/Self/TelemetryCommandTest.php +++ b/tests/phpunit/src/Commands/Self/TelemetryCommandTest.php @@ -5,11 +5,16 @@ namespace Acquia\Cli\Tests\Commands\Self; use Acquia\Cli\Command\App\LinkCommand; +use Acquia\Cli\Command\Auth\AuthLoginCommand; use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Command\Self\TelemetryCommand; use Acquia\Cli\Helpers\DataStoreContract; use Acquia\Cli\Tests\CommandTestBase; +use AcquiaCloudApi\Connector\Connector; +use Prophecy\Argument; +use ReflectionClass; use Symfony\Component\Filesystem\Path; +use Zumba\Amplitude\Amplitude; /** * @property \Acquia\Cli\Command\Self\TelemetryCommand $command @@ -90,6 +95,39 @@ public function testAmplitudeDisabled(): void $this->assertEquals(0, $this->getStatusCode()); } + /** + * Tests that sensitive option values are redacted from telemetry events. + */ + public function testTelemetryEventRedactsSensitiveOptions(): void + { + $amplitude = Amplitude::getInstance(); + $amplitude->setOptOut(false); + $amplitude->resetQueue(); + + $this->mockRequest('getAccount'); + $this->clientServiceProphecy->setConnector(Argument::type(Connector::class)) + ->shouldBeCalled(); + $this->clientServiceProphecy->isMachineAuthenticated() + ->willReturn(false); + $this->removeMockCloudConfigFile(); + $this->createDataStores(); + $this->command = $this->injectCommand(AuthLoginCommand::class); + + $this->executeCommand([ + '--key' => self::$key, + '--secret' => self::$secret, + ]); + + $this->assertTrue($amplitude->hasQueuedEvents()); + $queueProperty = (new ReflectionClass($amplitude))->getProperty('queue'); + $queuedEvents = $queueProperty->getValue($amplitude); + $event = json_encode(end($queuedEvents), JSON_THROW_ON_ERROR); + $this->assertStringContainsString('REDACTED', $event); + $this->assertStringNotContainsString(self::$key, $event); + $this->assertStringNotContainsString(self::$secret, $event); + $amplitude->resetQueue(); + } + public function testMigrateLegacyTelemetryPreference(): void { $this->createMockCloudConfigFile([DataStoreContract::SEND_TELEMETRY => null]); diff --git a/tests/phpunit/src/Commands/Ssh/SshKeyCreateCommandTest.php b/tests/phpunit/src/Commands/Ssh/SshKeyCreateCommandTest.php index bbbe1edb..eb81dfdf 100644 --- a/tests/phpunit/src/Commands/Ssh/SshKeyCreateCommandTest.php +++ b/tests/phpunit/src/Commands/Ssh/SshKeyCreateCommandTest.php @@ -129,6 +129,51 @@ public function testCreate(mixed $sshAddSuccess, mixed $args, mixed $inputs): vo $this->executeCommand($args, $inputs); } + /** + * Test that restrictive permissions are enforced on the private key and + * SSH directory after key generation, even if ssh-keygen (or whatever + * created the files) left them too permissive. + */ + public function testCreateEnforcesSecureKeyFilePermissions(): void + { + $privateKeyFilepath = Path::join($this->sshDir, self::$filename); + $publicKeyFilepath = $privateKeyFilepath . '.pub'; + $this->fs->remove($privateKeyFilepath); + $this->fs->remove($publicKeyFilepath); + $this->fs->chmod($this->sshDir, 0775); + $localMachineHelper = $this->mockLocalMachineHelper(); + $localMachineHelper->getLocalFilepath('~/.passphrase') + ->willReturn('~/.passphrase'); + + // Key is already in the agent, so addSshKeyToAgent is not called. + $this->mockSshAgentList($localMachineHelper, true); + $localMachineHelper->readFile(Argument::containingString(self::$filename)) + ->willReturn('thekey!'); + + // Mock ssh-keygen, simulating key files created with loose permissions. + $process = $this->prophet->prophesize(Process::class); + $process->isSuccessful()->willReturn(true); + $localMachineHelper->checkRequiredBinariesExist(['ssh-keygen']) + ->shouldBeCalled(); + $localMachineHelper->execute(Argument::withEntry(0, 'ssh-keygen'), null, null, false) + ->will(function () use ($process, $privateKeyFilepath, $publicKeyFilepath) { + file_put_contents($privateKeyFilepath, 'the private key'); + chmod($privateKeyFilepath, 0644); + file_put_contents($publicKeyFilepath, 'thekey!'); + return $process->reveal(); + }) + ->shouldBeCalled(); + + $this->executeCommand([ + '--filename' => self::$filename, + '--password' => 'acli123', + ], []); + + clearstatcache(); + $this->assertSame(0600, fileperms($privateKeyFilepath) & 0777, 'The private key must be chmod 0600 after creation.'); + $this->assertSame(0700, fileperms($this->sshDir) & 0777, 'The SSH directory must be chmod 0700 after key creation.'); + } + /** * Test that passwords with spaces are properly escaped in shell commands. */ diff --git a/tests/phpunit/src/DataStore/JsonDataStoreTest.php b/tests/phpunit/src/DataStore/JsonDataStoreTest.php new file mode 100644 index 00000000..8223f458 --- /dev/null +++ b/tests/phpunit/src/DataStore/JsonDataStoreTest.php @@ -0,0 +1,58 @@ +tempDir = sys_get_temp_dir() . '/acli_json_datastore_' . uniqid(); + (new Filesystem())->mkdir($this->tempDir); + $this->filepath = $this->tempDir . '/cloud_api.conf'; + } + + protected function tearDown(): void + { + (new Filesystem())->remove($this->tempDir); + parent::tearDown(); + } + + public function testDumpRestrictsFilePermissions(): void + { + $store = new JsonDataStore($this->filepath); + $store->set('acli_key', 'test-key'); + $store->dump(); + + clearstatcache(true, $this->filepath); + $this->assertTrue(is_readable($this->filepath)); + $this->assertSame(0600, fileperms($this->filepath) & 0777); + $this->assertSame('test-key', json_decode(file_get_contents($this->filepath), true)['acli_key']); + } + + public function testDumpRestrictsPermissionsOnExistingFile(): void + { + file_put_contents($this->filepath, json_encode(['acli_key' => 'test-key'], JSON_THROW_ON_ERROR)); + chmod($this->filepath, 0644); + + $store = new JsonDataStore($this->filepath); + $store->dump(); + + clearstatcache(true, $this->filepath); + $this->assertSame(0600, fileperms($this->filepath) & 0777); + } +} diff --git a/tests/phpunit/src/Misc/LocalMachineHelperTest.php b/tests/phpunit/src/Misc/LocalMachineHelperTest.php index 30439f07..0da8e886 100644 --- a/tests/phpunit/src/Misc/LocalMachineHelperTest.php +++ b/tests/phpunit/src/Misc/LocalMachineHelperTest.php @@ -29,6 +29,48 @@ public function testStartBrowser(): void putenv('DISPLAY'); } + public function testStartBrowserDoesNotShellInterpretUri(): void + { + putenv('DISPLAY=1'); + $markerFile = tempnam(sys_get_temp_dir(), 'acli_injection_'); + unlink($markerFile); + $uri = 'https://google.com/$(touch ' . $markerFile . ')'; + $opened = $this->localMachineHelper->startBrowser($uri, 'echo'); + $this->assertTrue($opened, 'Failed to open browser'); + $this->assertFileDoesNotExist($markerFile, 'The URI must be passed as a single argument and never be shell-interpreted'); + putenv('DISPLAY'); + } + + public function testStartBrowserWithMultiWordBrowserCommand(): void + { + putenv('DISPLAY=1'); + $opened = $this->localMachineHelper->startBrowser('https://google.com', 'echo -n'); + $this->assertTrue($opened, 'Failed to open browser with a multi-word browser command'); + putenv('DISPLAY'); + } + + public function testIsTtyStreamHandlesUnusableStreamWithoutErrorSuppression(): void + { + $errors = []; + set_error_handler(static function (int $errno, string $errstr) use (&$errors): bool { + $errors[] = $errstr; + return true; + }); + try { + $reflection = new \ReflectionClass($this->localMachineHelper); + $method = $reflection->getMethod('isTtyStream'); + // posix_isatty() emits a warning for streams that cannot be + // mapped to a file descriptor, such as in-memory streams. + $stream = fopen('php://memory', 'rb'); + $result = $method->invoke($this->localMachineHelper, $stream); + fclose($stream); + } finally { + restore_error_handler(); + } + $this->assertFalse($result, 'An unusable stream must be reported as not a TTY'); + $this->assertSame([], $errors, 'No PHP warning may leak out of isTtyStream()'); + } + /** * @return bool[][] */ diff --git a/tests/phpunit/src/Misc/TelemetryHelperTest.php b/tests/phpunit/src/Misc/TelemetryHelperTest.php index bbd933e3..a5711d75 100644 --- a/tests/phpunit/src/Misc/TelemetryHelperTest.php +++ b/tests/phpunit/src/Misc/TelemetryHelperTest.php @@ -109,6 +109,60 @@ public function testAhEnvNormalization(string $ah_env, string $expected): void $this->assertEquals($expected, $normalized_ah_env); } + /** + * @return array + */ + public static function providerTestRedactSensitiveData(): array + { + return [ + [['key' => 'mykey'], ['key' => 'REDACTED']], + [['secret' => 'mysecret'], ['secret' => 'REDACTED']], + [['password' => 'mypassword'], ['password' => 'REDACTED']], + [['token' => 'mytoken'], ['token' => 'REDACTED']], + [['api-key' => 'mykey'], ['api-key' => 'REDACTED']], + [['api-secret' => 'mysecret'], ['api-secret' => 'REDACTED']], + // Unset (null) values should not be redacted, lest it appear + // that a value was actually passed. + [['key' => null], ['key' => null]], + // Non-sensitive values should be left untouched. + [ + ['filename' => 'id_rsa', 'password' => 'foo'], + ['filename' => 'id_rsa', 'password' => 'REDACTED'], + ], + ]; + } + + /** + * @dataProvider providerTestRedactSensitiveData + * @param array $data + * @param array $expected + */ + public function testRedactSensitiveData(array $data, array $expected): void + { + $this->assertSame($expected, TelemetryHelper::redactSensitiveData($data)); + } + + /** + * @return array + */ + public static function providerTestRedactSensitiveContext(): array + { + return [ + ['ssh-key:create --password=foo', 'ssh-key:create --passwordREDACTED'], + ['auth:login --key=foo --secret=bar', 'auth:login --keyREDACTED'], + ['auth:login --secret=bar', 'auth:login --secretREDACTED'], + ['app:link myapp', 'app:link myapp'], + ]; + } + + /** + * @dataProvider providerTestRedactSensitiveContext + */ + public function testRedactSensitiveContext(string $context, string $expected): void + { + $this->assertSame($expected, TelemetryHelper::redactSensitiveContext($context)); + } + public function testIsBuildDateOlderThanMonthsNullDate(): void { $this->assertFalse(TelemetryHelper::isBuildDateOlderThanMonths(null, 3)); From bad2dee0087d305a163aacb007db8cc9e10e7dc5 Mon Sep 17 00:00:00 2001 From: Matthew Grasmick Date: Thu, 11 Jun 2026 00:01:28 -0400 Subject: [PATCH 09/17] Convert PHPUnit doc-comment metadata to attributes Replaces @group, @dataProvider, @covers, @coversDefaultClass, and @requires annotations with native PHP attributes across 56 test files. Doc-comment metadata is deprecated in PHPUnit 11 and removed in PHPUnit 12; this eliminates all 305 deprecation notices from the test run. Co-Authored-By: Claude Fable 5 --- tests/phpunit/src/AcsfApi/AcsfServiceTest.php | 5 ++-- .../ComposerScriptsListenerTest.php | 9 +++---- .../Application/ExceptionApplicationTest.php | 5 ++-- .../src/Application/HelpApplicationTest.php | 9 +++---- tests/phpunit/src/Application/KernelTest.php | 5 ++-- .../src/CloudApi/AcsfClientServiceTest.php | 5 ++-- .../src/CloudApi/ClientServiceTest.php | 5 ++-- .../src/CloudApi/ConnectorFactoryTest.php | 9 +++---- .../src/CloudApi/PathRewriteConnectorTest.php | 11 ++++---- .../src/Commands/Acsf/AcsfApiCommandTest.php | 5 ++-- .../Acsf/AcsfAuthLoginCommandTest.php | 12 ++++----- .../Acsf/AcsfAuthLogoutCommandTest.php | 5 ++-- .../src/Commands/Api/ApiCommandTest.php | 23 +++++----------- .../src/Commands/App/AppVcsInfoTest.php | 21 +++++---------- .../From/AbandonmentRecommendationTest.php | 26 ++----------------- .../Commands/App/From/ConfigurationTest.php | 3 ++- .../App/From/DefinedRecommendationTest.php | 5 ++-- .../Commands/App/From/ProjectBuilderTest.php | 3 ++- .../Commands/App/From/RecommendationsTest.php | 3 ++- .../src/Commands/App/LinkCommandTest.php | 5 ++-- .../src/Commands/App/NewCommandTest.php | 5 ++-- .../App/NewFromDrupal7CommandTest.php | 3 ++- .../src/Commands/App/TaskWaitCommandTest.php | 9 +++---- .../Commands/Auth/AuthLoginCommandTest.php | 5 ++-- .../CodeStudioPhpVersionCommandTest.php | 13 ++++------ .../CodeStudioPipelinesMigrateCommandTest.php | 14 +++++----- .../CodeStudioWizardCommandTest.php | 21 ++++++--------- .../phpunit/src/Commands/CommandBaseTest.php | 16 +++++------- .../phpunit/src/Commands/DocsCommandTest.php | 5 ++-- .../src/Commands/Env/EnvCreateCommandTest.php | 20 +++++--------- .../src/Commands/Env/EnvDeleteCommandTest.php | 16 +++++------- .../src/Commands/Ide/IdeCreateCommandTest.php | 5 ++-- .../src/Commands/Ide/IdeInfoCommandTest.php | 5 ++-- .../src/Commands/Ide/IdeListCommandTest.php | 9 +++---- .../src/Commands/Ide/IdeOpenCommandTest.php | 5 ++-- .../Commands/Ide/IdePhpVersionCommandTest.php | 9 +++---- .../Ide/IdeServiceRestartCommandTest.php | 5 ++-- .../Ide/IdeServiceStartCommandTest.php | 5 ++-- .../Ide/IdeServiceStopCommandTest.php | 5 ++-- .../Ide/IdeXdebugToggleCommandTest.php | 9 +++---- .../IdeWizardCreateSshKeyCommandTest.php | 12 ++++----- .../src/Commands/Pull/PullCodeCommandTest.php | 5 ++-- .../Commands/Pull/PullDatabaseCommandTest.php | 5 ++-- .../Commands/Push/PushArtifactCommandTest.php | 5 ++-- .../Commands/Push/PushDatabaseCommandTest.php | 5 ++-- .../Remote/AliasesDownloadCommandTest.php | 8 +++--- .../src/Commands/Remote/DrushCommandTest.php | 5 ++-- .../src/Commands/Remote/SshCommandTest.php | 5 ++-- .../Commands/Self/ClearCacheCommandTest.php | 5 ++-- .../Commands/Self/TelemetryCommandTest.php | 8 +++--- .../Commands/Ssh/SshKeyCreateCommandTest.php | 5 ++-- .../Commands/Ssh/SshKeyUploadCommandTest.php | 5 ++-- tests/phpunit/src/Misc/ChecklistTest.php | 5 ++-- .../src/Misc/ExceptionListenerTest.php | 5 ++-- .../src/Misc/LocalMachineHelperTest.php | 11 ++++---- .../phpunit/src/Misc/TelemetryHelperTest.php | 21 +++++++-------- 56 files changed, 180 insertions(+), 293 deletions(-) diff --git a/tests/phpunit/src/AcsfApi/AcsfServiceTest.php b/tests/phpunit/src/AcsfApi/AcsfServiceTest.php index 6030eae1..363bf340 100644 --- a/tests/phpunit/src/AcsfApi/AcsfServiceTest.php +++ b/tests/phpunit/src/AcsfApi/AcsfServiceTest.php @@ -9,6 +9,7 @@ use Acquia\Cli\AcsfApi\AcsfCredentials; use Acquia\Cli\Application; use Acquia\Cli\Tests\TestBase; +use PHPUnit\Framework\Attributes\DataProvider; class AcsfServiceTest extends TestBase { @@ -43,9 +44,7 @@ public static function providerTestIsMachineAuthenticated(): array ]; } - /** - * @dataProvider providerTestIsMachineAuthenticated - */ + #[DataProvider('providerTestIsMachineAuthenticated')] public function testIsMachineAuthenticated(array $envVars, bool $isAuthenticated): void { self::setEnvVars($envVars); diff --git a/tests/phpunit/src/Application/ComposerScriptsListenerTest.php b/tests/phpunit/src/Application/ComposerScriptsListenerTest.php index b2565157..d92374fd 100644 --- a/tests/phpunit/src/Application/ComposerScriptsListenerTest.php +++ b/tests/phpunit/src/Application/ComposerScriptsListenerTest.php @@ -7,6 +7,7 @@ use Acquia\Cli\Command\HelloWorldCommand; use Acquia\Cli\EventListener\ComposerScriptsListener; use Acquia\Cli\Tests\ApplicationTestBase; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\Console\Event\ConsoleCommandEvent; use Symfony\Component\Console\Event\ConsoleTerminateEvent; use Symfony\Component\Filesystem\Path; @@ -19,9 +20,7 @@ */ class ComposerScriptsListenerTest extends ApplicationTestBase { - /** - * @group serial - */ + #[Group('serial')] public function testPreScripts(): void { $json = [ @@ -43,9 +42,7 @@ public function testPreScripts(): void self::assertStringContainsString('pre-acli-hello-world', $buffer); } - /** - * @group serial - */ + #[Group('serial')] public function testPostScripts(): void { $json = [ diff --git a/tests/phpunit/src/Application/ExceptionApplicationTest.php b/tests/phpunit/src/Application/ExceptionApplicationTest.php index a37f9e67..03d75143 100644 --- a/tests/phpunit/src/Application/ExceptionApplicationTest.php +++ b/tests/phpunit/src/Application/ExceptionApplicationTest.php @@ -5,6 +5,7 @@ namespace Acquia\Cli\Tests\Application; use Acquia\Cli\Tests\ApplicationTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests exceptions rewritten by the Symfony Event Dispatcher. @@ -17,9 +18,7 @@ */ class ExceptionApplicationTest extends ApplicationTestBase { - /** - * @group serial - */ + #[Group('serial')] public function testInvalidApiCredentials(): void { $this->setInput([ diff --git a/tests/phpunit/src/Application/HelpApplicationTest.php b/tests/phpunit/src/Application/HelpApplicationTest.php index cdbab643..f57af8ce 100644 --- a/tests/phpunit/src/Application/HelpApplicationTest.php +++ b/tests/phpunit/src/Application/HelpApplicationTest.php @@ -5,6 +5,7 @@ namespace Acquia\Cli\Tests\Application; use Acquia\Cli\Tests\ApplicationTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Test the 'help' command. @@ -15,9 +16,7 @@ */ class HelpApplicationTest extends ApplicationTestBase { - /** - * @group serial - */ + #[Group('serial')] public function testApplicationAliasHelp(): void { $this->setInput([ @@ -35,9 +34,7 @@ public function testApplicationAliasHelp(): void app:link abcd1234-1111-2222-3333-0e02b2c3d470', $buffer); } - /** - * @group serial - */ + #[Group('serial')] public function testEnvironmentAliasHelp(): void { $this->setInput([ diff --git a/tests/phpunit/src/Application/KernelTest.php b/tests/phpunit/src/Application/KernelTest.php index b2c5a1a8..e0ad1a60 100644 --- a/tests/phpunit/src/Application/KernelTest.php +++ b/tests/phpunit/src/Application/KernelTest.php @@ -5,12 +5,11 @@ namespace Acquia\Cli\Tests\Application; use Acquia\Cli\Tests\ApplicationTestBase; +use PHPUnit\Framework\Attributes\Group; class KernelTest extends ApplicationTestBase { - /** - * @group serial - */ + #[Group('serial')] public function testRun(): void { $this->setInput([ diff --git a/tests/phpunit/src/CloudApi/AcsfClientServiceTest.php b/tests/phpunit/src/CloudApi/AcsfClientServiceTest.php index 939152a2..ad0802a7 100644 --- a/tests/phpunit/src/CloudApi/AcsfClientServiceTest.php +++ b/tests/phpunit/src/CloudApi/AcsfClientServiceTest.php @@ -11,6 +11,7 @@ use Acquia\Cli\Tests\TestBase; use AcquiaCloudApi\Exception\ApiErrorException; use GuzzleHttp\Psr7\Response; +use PHPUnit\Framework\Attributes\DataProvider; class AcsfClientServiceTest extends TestBase { @@ -43,9 +44,7 @@ public static function providerTestIsMachineAuthenticated(): array ]; } - /** - * @dataProvider providerTestIsMachineAuthenticated - */ + #[DataProvider('providerTestIsMachineAuthenticated')] public function testIsMachineAuthenticated(array $envVars, bool $isAuthenticated): void { self::setEnvVars($envVars); diff --git a/tests/phpunit/src/CloudApi/ClientServiceTest.php b/tests/phpunit/src/CloudApi/ClientServiceTest.php index f859ac41..f7f5103b 100644 --- a/tests/phpunit/src/CloudApi/ClientServiceTest.php +++ b/tests/phpunit/src/CloudApi/ClientServiceTest.php @@ -9,6 +9,7 @@ use Acquia\Cli\CloudApi\ConnectorFactory; use Acquia\Cli\DataStore\CloudDataStore; use Acquia\Cli\Tests\TestBase; +use PHPUnit\Framework\Attributes\DataProvider; class ClientServiceTest extends TestBase { @@ -53,9 +54,7 @@ public static function providerTestIsMachineAuthenticated(): array ]; } - /** - * @dataProvider providerTestIsMachineAuthenticated - */ + #[DataProvider('providerTestIsMachineAuthenticated')] public function testIsMachineAuthenticated(array $envVars, bool $isAuthenticated): void { self::setEnvVars($envVars); diff --git a/tests/phpunit/src/CloudApi/ConnectorFactoryTest.php b/tests/phpunit/src/CloudApi/ConnectorFactoryTest.php index 43c9e521..1af9873b 100644 --- a/tests/phpunit/src/CloudApi/ConnectorFactoryTest.php +++ b/tests/phpunit/src/CloudApi/ConnectorFactoryTest.php @@ -7,14 +7,15 @@ use Acquia\Cli\CloudApi\ConnectorFactory; use Acquia\Cli\CloudApi\PathRewriteConnector; use AcquiaCloudApi\Connector\Connector; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; /** - * @covers \Acquia\Cli\CloudApi\ConnectorFactory - * * Unit tests for the ConnectorFactory. Ensures that the factory returns the correct * connector type depending on the presence of the AH_CODEBASE_UUID environment variable. */ +#[CoversClass(ConnectorFactory::class)] class ConnectorFactoryTest extends TestCase { /** @@ -32,9 +33,7 @@ protected function setUp(): void } - /** - * @dataProvider connectorFactoryProvider - */ + #[DataProvider('connectorFactoryProvider')] public function testCreateConnectorFactoryBehavior(?string $envValue, string $expectedClass): void { if ($envValue !== null) { diff --git a/tests/phpunit/src/CloudApi/PathRewriteConnectorTest.php b/tests/phpunit/src/CloudApi/PathRewriteConnectorTest.php index 15ca54c2..f09db1b4 100644 --- a/tests/phpunit/src/CloudApi/PathRewriteConnectorTest.php +++ b/tests/phpunit/src/CloudApi/PathRewriteConnectorTest.php @@ -6,16 +6,17 @@ use Acquia\Cli\CloudApi\PathRewriteConnector; use AcquiaCloudApi\Connector\ConnectorInterface; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; /** - * @covers \Acquia\Cli\CloudApi\PathRewriteConnector - * * Unit tests for the PathRewriteConnector decorator. Ensures all path rewriting logic, * delegation, and error handling are correct. */ +#[CoversClass(PathRewriteConnector::class)] class PathRewriteConnectorTest extends TestCase { /** @@ -47,11 +48,11 @@ protected function setUp(): void } /** - * @dataProvider createRequestProvider * @param string $verb The HTTP verb to test. * @param string $inputPath The input path to test. * @param string $expectedPath The expected path after rewriting. */ + #[DataProvider('createRequestProvider')] public function testCreateRequestPathRewriting(string $verb, string $inputPath, string $expectedPath): void { $mock = $this->createMock(RequestInterface::class); @@ -66,12 +67,12 @@ public function testCreateRequestPathRewriting(string $verb, string $inputPath, /** - * @dataProvider sendRequestProvider * @param string $verb The HTTP verb to test. * @param string $inputPath The input path to test. * @param string $expectedPath The expected path after rewriting. * @param array $options The options to pass to sendRequest. */ + #[DataProvider('sendRequestProvider')] public function testSendRequestPathRewriting(string $verb, string $inputPath, string $expectedPath, array $options): void { $mock = $this->createMock(ResponseInterface::class); @@ -84,10 +85,10 @@ public function testSendRequestPathRewriting(string $verb, string $inputPath, st } /** - * @dataProvider delegationProvider * @param string $method The method to test delegation for. * @param mixed $expected The expected return value from the inner connector. */ + #[DataProvider('delegationProvider')] public function testDelegation(string $method, string $expected): void { $this->inner->expects($this->once()) diff --git a/tests/phpunit/src/Commands/Acsf/AcsfApiCommandTest.php b/tests/phpunit/src/Commands/Acsf/AcsfApiCommandTest.php index f1ac31bc..7039f67a 100644 --- a/tests/phpunit/src/Commands/Acsf/AcsfApiCommandTest.php +++ b/tests/phpunit/src/Commands/Acsf/AcsfApiCommandTest.php @@ -12,6 +12,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\CommandFactoryInterface; use Acquia\Cli\Exception\AcquiaCliException; +use PHPUnit\Framework\Attributes\DataProvider; use Prophecy\Argument; use Symfony\Component\Console\Output\OutputInterface; @@ -164,9 +165,7 @@ public static function providerTestAcsfCommandExecutionForHttpGetMultiple(): arr ]; } - /** - * @dataProvider providerTestAcsfCommandExecutionForHttpGetMultiple - */ + #[DataProvider('providerTestAcsfCommandExecutionForHttpGetMultiple')] public function testAcsfCommandExecutionForHttpGetMultiple(string $method, string $specPath, string $path, string $command, array $arguments = [], array $jsonArguments = []): void { $mockBody = self::getMockResponseFromSpec($specPath, $method, '200', true); diff --git a/tests/phpunit/src/Commands/Acsf/AcsfAuthLoginCommandTest.php b/tests/phpunit/src/Commands/Acsf/AcsfAuthLoginCommandTest.php index 94480aaa..63327cc5 100644 --- a/tests/phpunit/src/Commands/Acsf/AcsfAuthLoginCommandTest.php +++ b/tests/phpunit/src/Commands/Acsf/AcsfAuthLoginCommandTest.php @@ -9,6 +9,8 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Config\CloudDataConfig; use Acquia\Cli\DataStore\CloudDataStore; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\RequiresOperatingSystem; use Symfony\Component\Validator\Exception\ValidatorException; /** @@ -120,10 +122,8 @@ public static function providerTestAuthLoginCommand(): array ]; } - /** - * @dataProvider providerTestAuthLoginCommand - * @requires OS linux|darwin - */ + #[DataProvider('providerTestAuthLoginCommand')] + #[RequiresOperatingSystem('linux|darwin')] public function testAcsfAuthLoginCommand(bool $machineIsAuthenticated, array $inputs, array $args, string $outputToAssert, array $config = []): void { if (!$machineIsAuthenticated) { @@ -167,9 +167,9 @@ public static function providerTestAcsfAuthLoginInvalid(): array } /** - * @dataProvider providerTestAcsfAuthLoginInvalid * @throws \Exception */ + #[DataProvider('providerTestAcsfAuthLoginInvalid')] public function testAcsfAuthLoginInvalid(array $args, string $message): void { $this->clientServiceProphecy->isMachineAuthenticated() @@ -198,9 +198,9 @@ public static function providerTestAcsfAuthLoginInvalidInput(): array } /** - * @dataProvider providerTestAcsfAuthLoginInvalidInput * @throws \JsonException */ + #[DataProvider('providerTestAcsfAuthLoginInvalidInput')] public function testAcsfAuthLoginInvalidInput(array $input): void { $this->removeMockCloudConfigFile(); diff --git a/tests/phpunit/src/Commands/Acsf/AcsfAuthLogoutCommandTest.php b/tests/phpunit/src/Commands/Acsf/AcsfAuthLogoutCommandTest.php index f4621e9f..783b7191 100644 --- a/tests/phpunit/src/Commands/Acsf/AcsfAuthLogoutCommandTest.php +++ b/tests/phpunit/src/Commands/Acsf/AcsfAuthLogoutCommandTest.php @@ -9,6 +9,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Config\CloudDataConfig; use Acquia\Cli\DataStore\CloudDataStore; +use PHPUnit\Framework\Attributes\DataProvider; /** * @property AcsfAuthLogoutCommandTest $command @@ -49,9 +50,7 @@ public static function providerTestAuthLogoutCommand(): array ]; } - /** - * @dataProvider providerTestAuthLogoutCommand - */ + #[DataProvider('providerTestAuthLogoutCommand')] public function testAcsfAuthLogoutCommand(bool $machineIsAuthenticated, array $inputs, array $config = []): void { if (!$machineIsAuthenticated) { diff --git a/tests/phpunit/src/Commands/Api/ApiCommandTest.php b/tests/phpunit/src/Commands/Api/ApiCommandTest.php index 8a3ac9a1..5defe300 100644 --- a/tests/phpunit/src/Commands/Api/ApiCommandTest.php +++ b/tests/phpunit/src/Commands/Api/ApiCommandTest.php @@ -10,6 +10,8 @@ use Acquia\Cli\Exception\AcquiaCliException; use Acquia\Cli\Tests\CommandTestBase; use AcquiaCloudApi\Exception\ApiErrorException; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\Console\Exception\MissingInputException; use Symfony\Component\Filesystem\Path; use Symfony\Component\Validator\Exception\ValidatorException; @@ -245,10 +247,8 @@ public static function providerTestConvertApplicationAliasToUuidArgument(): arra ]; } - /** - * @dataProvider providerTestConvertApplicationAliasToUuidArgument - * @group serial - */ + #[DataProvider('providerTestConvertApplicationAliasToUuidArgument')] + #[Group('serial')] public function testConvertApplicationAliasToUuidArgument(bool $support): void { ClearCacheCommand::clearCaches(); @@ -379,9 +379,7 @@ public function testConvertEnvironmentAliasToUuidArgument(): void $this->assertEquals(0, $this->getStatusCode()); } - /** - * @group serial - */ + #[Group('serial')] public function testConvertInvalidEnvironmentAliasToUuidArgument(): void { ClearCacheCommand::clearCaches(); @@ -494,9 +492,7 @@ public static function providerTestApiCommandDefinitionParameters(): array ]; } - /** - * @dataProvider providerTestApiCommandDefinitionParameters - */ + #[DataProvider('providerTestApiCommandDefinitionParameters')] public function testApiCommandDefinitionParameters(string $useSpecCache, string $commandName, string $method, string $usage): void { putenv('ACQUIA_CLI_USE_CLOUD_API_SPEC_CACHE=' . $useSpecCache); @@ -591,12 +587,7 @@ public static function providerTestApiCommandDefinitionRequestBody(): array ]; } - /** - * @dataProvider providerTestApiCommandDefinitionRequestBody - * @param $commandName - * @param $method - * @param $usage - */ + #[DataProvider('providerTestApiCommandDefinitionRequestBody')] public function testApiCommandDefinitionRequestBody(string $commandName, string $method, array $usage): void { $this->command = $this->getApiCommandByName($commandName); diff --git a/tests/phpunit/src/Commands/App/AppVcsInfoTest.php b/tests/phpunit/src/Commands/App/AppVcsInfoTest.php index 7f03c148..c22936e8 100644 --- a/tests/phpunit/src/Commands/App/AppVcsInfoTest.php +++ b/tests/phpunit/src/Commands/App/AppVcsInfoTest.php @@ -8,6 +8,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Exception\AcquiaCliException; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\Group; /** * @property \Acquia\Cli\Command\App\AppVcsInfo $command @@ -19,9 +20,7 @@ protected function createCommand(): CommandBase return $this->injectCommand(AppVcsInfo::class); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testNoEnvAvailableCommand(): void { $applications = $this->mockRequest('getApplications'); @@ -45,9 +44,7 @@ public function testNoEnvAvailableCommand(): void ); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testNoVcsAvailableCommand(): void { $applications = $this->mockRequest('getApplications'); @@ -70,9 +67,7 @@ public function testNoVcsAvailableCommand(): void ); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testShowVcsListCommand(): void { $applications = $this->mockRequest('getApplications'); @@ -103,9 +98,7 @@ public function testShowVcsListCommand(): void self::assertStringContainsStringIgnoringLineEndings($expected, $output); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testNoDeployedVcs(): void { $applications = $this->mockRequest('getApplications'); @@ -134,9 +127,7 @@ public function testNoDeployedVcs(): void ); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testListOnlyDeployedVcs(): void { $applications = $this->mockRequest('getApplications'); diff --git a/tests/phpunit/src/Commands/App/From/AbandonmentRecommendationTest.php b/tests/phpunit/src/Commands/App/From/AbandonmentRecommendationTest.php index c7bed4ba..e60b2fc4 100644 --- a/tests/phpunit/src/Commands/App/From/AbandonmentRecommendationTest.php +++ b/tests/phpunit/src/Commands/App/From/AbandonmentRecommendationTest.php @@ -6,11 +6,10 @@ use Acquia\Cli\Command\App\From\Recommendation\AbandonmentRecommendation; use Acquia\Cli\Command\App\From\Recommendation\DefinedRecommendation; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -/** - * @coversDefaultClass \Acquia\Cli\Command\App\From\Recommendation\AbandonmentRecommendation - */ +#[CoversClass(AbandonmentRecommendation::class)] class AbandonmentRecommendationTest extends TestCase { private AbandonmentRecommendation $sut; @@ -31,63 +30,42 @@ protected function setUp(): void // phpcs:enable } - /** - * @covers ::getPackageName - */ public function testPackageName(): void { $this->expectException(\LogicException::class); $this->sut->getPackageName(); } - /** - * @covers ::getVersionConstraint - */ public function testVersionConstraint(): void { $this->expectException(\LogicException::class); $this->sut->getVersionConstraint(); } - /** - * @covers ::hasModulesToInstall - */ public function testHasModulesToInstall(): void { $this->expectException(\LogicException::class); $this->sut->hasModulesToInstall(); } - /** - * @covers ::getModulesToInstall - */ public function testGetModulesToInstall(): void { $this->expectException(\LogicException::class); $this->sut->getModulesToInstall(); } - /** - * @covers ::hasPatches - */ public function testHasPatches(): void { $this->expectException(\LogicException::class); $this->sut->hasPatches(); } - /** - * @covers ::isVetted - */ public function testIsVetted(): void { $this->expectException(\LogicException::class); $this->sut->isVetted(); } - /** - * @covers ::getPatches - */ public function testGetPatches(): void { $this->expectException(\LogicException::class); diff --git a/tests/phpunit/src/Commands/App/From/ConfigurationTest.php b/tests/phpunit/src/Commands/App/From/ConfigurationTest.php index db588e56..41ff9d78 100644 --- a/tests/phpunit/src/Commands/App/From/ConfigurationTest.php +++ b/tests/phpunit/src/Commands/App/From/ConfigurationTest.php @@ -8,6 +8,7 @@ use DomainException; use Exception; use JsonException; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; @@ -20,8 +21,8 @@ class ConfigurationTest extends TestCase /** * @param string $configuration * A JSON string from which to create a configuration object. - * @dataProvider getTestConfigurations */ + #[DataProvider('getTestConfigurations')] public function test(string $configuration, Exception $expected_exception): void { $test_stream = fopen('php://memory', 'rw'); diff --git a/tests/phpunit/src/Commands/App/From/DefinedRecommendationTest.php b/tests/phpunit/src/Commands/App/From/DefinedRecommendationTest.php index 1211c944..1312eeb2 100755 --- a/tests/phpunit/src/Commands/App/From/DefinedRecommendationTest.php +++ b/tests/phpunit/src/Commands/App/From/DefinedRecommendationTest.php @@ -10,6 +10,7 @@ use Acquia\Cli\Command\App\From\Recommendation\RecommendationInterface; use Acquia\Cli\Command\App\From\Recommendation\UniversalRecommendation; use Acquia\Cli\Command\App\From\SourceSite\ExtensionInterface; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; @@ -17,9 +18,7 @@ class DefinedRecommendationTest extends TestCase { use ProphecyTrait; - /** - * @dataProvider getTestConfigurations - */ + #[DataProvider('getTestConfigurations')] public function test(mixed $configuration, RecommendationInterface $expected): void { $actual = DefinedRecommendation::createFromDefinition($configuration); diff --git a/tests/phpunit/src/Commands/App/From/ProjectBuilderTest.php b/tests/phpunit/src/Commands/App/From/ProjectBuilderTest.php index 8d6910e5..e59473c9 100644 --- a/tests/phpunit/src/Commands/App/From/ProjectBuilderTest.php +++ b/tests/phpunit/src/Commands/App/From/ProjectBuilderTest.php @@ -8,15 +8,16 @@ use Acquia\Cli\Command\App\From\Configuration; use Acquia\Cli\Command\App\From\Recommendation\Recommendations; use Acquia\Cli\Command\App\From\Recommendation\Resolver; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class ProjectBuilderTest extends TestCase { /** - * @dataProvider getTestResources * @param resource $configuration_resource * @param resource $recommendations_resource */ + #[DataProvider('getTestResources')] public function test($configuration_resource, $recommendations_resource, array $expected_project_definition): void { assert(is_resource($configuration_resource)); diff --git a/tests/phpunit/src/Commands/App/From/RecommendationsTest.php b/tests/phpunit/src/Commands/App/From/RecommendationsTest.php index 753ab41c..86249c22 100644 --- a/tests/phpunit/src/Commands/App/From/RecommendationsTest.php +++ b/tests/phpunit/src/Commands/App/From/RecommendationsTest.php @@ -7,6 +7,7 @@ use Acquia\Cli\Command\App\From\Recommendation\RecommendationInterface; use Acquia\Cli\Command\App\From\Recommendation\Recommendations; use Acquia\Cli\Command\App\From\SourceSite\ExtensionInterface; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; @@ -23,8 +24,8 @@ class RecommendationsTest extends TestCase * An expected recommendation or a JSON exception in the case that the * given * $configuration is malformed. - * @dataProvider getTestConfigurations */ + #[DataProvider('getTestConfigurations')] public function test(string $configuration, mixed $expectation): void { $test_stream = fopen('php://memory', 'rw'); diff --git a/tests/phpunit/src/Commands/App/LinkCommandTest.php b/tests/phpunit/src/Commands/App/LinkCommandTest.php index 1f9a518f..9398e749 100644 --- a/tests/phpunit/src/Commands/App/LinkCommandTest.php +++ b/tests/phpunit/src/Commands/App/LinkCommandTest.php @@ -8,6 +8,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Exception\AcquiaCliException; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\Group; /** * @property \Acquia\Cli\Command\App\LinkCommand $command @@ -52,9 +53,7 @@ public function testLinkCommandAlreadyLinked(): void $this->assertEquals(1, $this->getStatusCode()); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testLinkCommandInvalidDir(): void { $this->mockRequest('getApplications'); diff --git a/tests/phpunit/src/Commands/App/NewCommandTest.php b/tests/phpunit/src/Commands/App/NewCommandTest.php index 7bab45b6..4d322ff4 100644 --- a/tests/phpunit/src/Commands/App/NewCommandTest.php +++ b/tests/phpunit/src/Commands/App/NewCommandTest.php @@ -7,6 +7,7 @@ use Acquia\Cli\Command\App\NewCommand; use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\DataProvider; use Prophecy\Prophecy\ObjectProphecy; use Symfony\Component\Filesystem\Path; use Symfony\Component\Process\Process; @@ -45,9 +46,7 @@ public static function provideTestNewDrupalCommand(): array ]; } - /** - * @dataProvider provideTestNewDrupalCommand - */ + #[DataProvider('provideTestNewDrupalCommand')] public function testNewDrupalCommand(array $package, string $directory = 'drupal'): void { $this->newProjectDir = Path::makeAbsolute($directory, $this->projectDir); diff --git a/tests/phpunit/src/Commands/App/NewFromDrupal7CommandTest.php b/tests/phpunit/src/Commands/App/NewFromDrupal7CommandTest.php index 2d57eddd..cf7ac5b9 100644 --- a/tests/phpunit/src/Commands/App/NewFromDrupal7CommandTest.php +++ b/tests/phpunit/src/Commands/App/NewFromDrupal7CommandTest.php @@ -7,6 +7,7 @@ use Acquia\Cli\Command\App\NewFromDrupal7Command; use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\DataProvider; use Prophecy\Prophecy\ObjectProphecy; use Symfony\Component\Process\Process; @@ -80,8 +81,8 @@ protected function assertValidDateFormat(string $date, string $format): void * that would be provided to the --recommendations CLI option. * @param string $expected_json * The expected output. - * @dataProvider provideTestNewFromDrupal7Command */ + #[DataProvider('provideTestNewFromDrupal7Command')] public function testNewFromDrupal7Command(string $extensions_json, string $recommendations_json, string $expected_json): void { foreach (func_get_args() as $file) { diff --git a/tests/phpunit/src/Commands/App/TaskWaitCommandTest.php b/tests/phpunit/src/Commands/App/TaskWaitCommandTest.php index 2e343fc3..6d194dca 100644 --- a/tests/phpunit/src/Commands/App/TaskWaitCommandTest.php +++ b/tests/phpunit/src/Commands/App/TaskWaitCommandTest.php @@ -8,6 +8,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Exception\AcquiaCliException; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Component\Console\Command\Command; /** @@ -20,9 +21,7 @@ protected function createCommand(): CommandBase return $this->injectCommand(TaskWaitCommand::class); } - /** - * @dataProvider providerTestTaskWaitCommand - */ + #[DataProvider('providerTestTaskWaitCommand')] public function testTaskWaitCommand(string $notification): void { $notificationUuid = '1bd3487e-71d1-4fca-a2d9-5f969b3d35c1'; @@ -113,9 +112,7 @@ public function testTaskWaitCommandWithInvalidUrl(): void // Assert. } - /** - * @dataProvider providerTestTaskWaitCommandWithInvalidJson - */ + #[DataProvider('providerTestTaskWaitCommandWithInvalidJson')] public function testTaskWaitCommandWithInvalidJson(string $notification): void { $this->expectException(AcquiaCliException::class); diff --git a/tests/phpunit/src/Commands/Auth/AuthLoginCommandTest.php b/tests/phpunit/src/Commands/Auth/AuthLoginCommandTest.php index 3f987562..7a790056 100644 --- a/tests/phpunit/src/Commands/Auth/AuthLoginCommandTest.php +++ b/tests/phpunit/src/Commands/Auth/AuthLoginCommandTest.php @@ -12,6 +12,7 @@ use Acquia\Cli\Tests\CommandTestBase; use AcquiaCloudApi\Connector\Connector; use Generator; +use PHPUnit\Framework\Attributes\DataProvider; use Prophecy\Argument; use Symfony\Component\Validator\Exception\ValidatorException; @@ -87,9 +88,7 @@ public static function providerTestAuthLoginInvalidInputCommand(): Generator ]; } - /** - * @dataProvider providerTestAuthLoginInvalidInputCommand - */ + #[DataProvider('providerTestAuthLoginInvalidInputCommand')] public function testAuthLoginInvalidInputCommand(array $inputs, array $args): void { $this->clientServiceProphecy->isMachineAuthenticated() diff --git a/tests/phpunit/src/Commands/CodeStudio/CodeStudioPhpVersionCommandTest.php b/tests/phpunit/src/Commands/CodeStudio/CodeStudioPhpVersionCommandTest.php index 761a7df3..ec2fbda8 100644 --- a/tests/phpunit/src/Commands/CodeStudio/CodeStudioPhpVersionCommandTest.php +++ b/tests/phpunit/src/Commands/CodeStudio/CodeStudioPhpVersionCommandTest.php @@ -9,6 +9,8 @@ use Acquia\Cli\Tests\CommandTestBase; use Gitlab\Client; use Gitlab\Exception\RuntimeException; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; use Prophecy\Argument; use Symfony\Component\Validator\Exception\ValidatorException; @@ -45,9 +47,8 @@ public static function providerTestPhpVersionFailure(): array /** * Test for the wrong PHP version passed as argument. - * - * @dataProvider providerTestPhpVersionFailure */ + #[DataProvider('providerTestPhpVersionFailure')] public function testPhpVersionFailure(mixed $phpVersion): void { $this->expectException(ValidatorException::class); @@ -120,9 +121,7 @@ public function testPhpVersionAddFail(): void $this->assertStringContainsString('Unable to update the PHP version to 8.1', $output); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testPhpVersionAdd(): void { $this->mockApplicationRequest(); @@ -196,9 +195,7 @@ public function testPhpVersionUpdateFail(): void $this->assertStringContainsString('Unable to update the PHP version to 8.1', $output); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testPhpVersionUpdate(): void { $this->mockApplicationRequest(); diff --git a/tests/phpunit/src/Commands/CodeStudio/CodeStudioPipelinesMigrateCommandTest.php b/tests/phpunit/src/Commands/CodeStudio/CodeStudioPipelinesMigrateCommandTest.php index 359d6784..bde61230 100644 --- a/tests/phpunit/src/Commands/CodeStudio/CodeStudioPipelinesMigrateCommandTest.php +++ b/tests/phpunit/src/Commands/CodeStudio/CodeStudioPipelinesMigrateCommandTest.php @@ -12,6 +12,9 @@ use Gitlab\Api\ProjectNamespaces; use Gitlab\Api\Projects; use Gitlab\Client; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\RequiresOperatingSystem; use Prophecy\Argument; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Path; @@ -20,8 +23,8 @@ /** * @property \Acquia\Cli\Command\CodeStudio\CodeStudioPipelinesMigrateCommand * $command - * @requires OS linux|darwin */ +#[RequiresOperatingSystem('linux|darwin')] class CodeStudioPipelinesMigrateCommandTest extends CommandTestBase { use IdeRequiredTestTrait; @@ -99,13 +102,8 @@ public static function providerTestCommand(): array ]; } - /** - * @dataProvider providerTestCommand - * @param $mockedGitlabProjects - * @param $args - * @param $inputs - * @group brokenProphecy - */ + #[DataProvider('providerTestCommand')] + #[Group('brokenProphecy')] public function testCommand(mixed $mockedGitlabProjects, mixed $inputs, mixed $args): void { copy( diff --git a/tests/phpunit/src/Commands/CodeStudio/CodeStudioWizardCommandTest.php b/tests/phpunit/src/Commands/CodeStudio/CodeStudioWizardCommandTest.php index 6645e52d..478a64a5 100644 --- a/tests/phpunit/src/Commands/CodeStudio/CodeStudioWizardCommandTest.php +++ b/tests/phpunit/src/Commands/CodeStudio/CodeStudioWizardCommandTest.php @@ -16,13 +16,16 @@ use Gitlab\Api\ProjectNamespaces; use Gitlab\Api\Schedules; use Gitlab\Client; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\RequiresOperatingSystem; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; /** * @property \Acquia\Cli\Command\CodeStudio\CodeStudioWizardCommand $command - * @requires OS linux|darwin */ +#[RequiresOperatingSystem('linux|darwin')] class CodeStudioWizardCommandTest extends WizardTestBase { use IdeRequiredTestTrait; @@ -187,9 +190,7 @@ public static function providerTestCommand(): array ]; } - /** - * @dataProvider providerTestCommand - */ + #[DataProvider('providerTestCommand')] public function testCommand(array $mockedGitlabProjects, array $inputs, array $args): void { $this->clientServiceProphecy->setConnector(Argument::type(Connector::class)) @@ -331,9 +332,7 @@ public static function providerTestCommandCodebase(): array ]; } - /** - * @dataProvider providerTestCommandCodebase - */ + #[DataProvider('providerTestCommandCodebase')] public function testCommandCodebase(array $mockedGitlabProjects, array $inputs, array $args): void { $this->clientServiceProphecy->setConnector(Argument::type(Connector::class)) @@ -437,9 +436,7 @@ public function testCommandCodebase(array $mockedGitlabProjects, array $inputs, self::assertStringContainsString('Codebase', $output); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testInvalidGitLabCredentials(): void { $localMachineHelper = $this->mockLocalMachineHelper(); @@ -455,9 +452,7 @@ public function testInvalidGitLabCredentials(): void ]); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testMissingGitLabCredentials(): void { $localMachineHelper = $this->mockLocalMachineHelper(); diff --git a/tests/phpunit/src/Commands/CommandBaseTest.php b/tests/phpunit/src/Commands/CommandBaseTest.php index 1ba70b09..df2bf068 100644 --- a/tests/phpunit/src/Commands/CommandBaseTest.php +++ b/tests/phpunit/src/Commands/CommandBaseTest.php @@ -18,6 +18,8 @@ use Acquia\Cli\Exception\AcquiaCliException; use Acquia\Cli\Tests\Commands\Ide\IdeHelper; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\Validator\Exception\ValidatorException; /** @@ -107,10 +109,8 @@ public static function providerTestCloudAppUuidArg(): array ]; } - /** - * @dataProvider providerTestCloudAppUuidArg - * @group brokenProphecy - */ + #[DataProvider('providerTestCloudAppUuidArg')] + #[Group('brokenProphecy')] public function testCloudAppUuidArg(string $uuid): void { $this->mockApplicationRequest(); @@ -134,9 +134,7 @@ public static function providerTestInvalidCloudAppUuidArg(): array ]; } - /** - * @dataProvider providerTestInvalidCloudAppUuidArg - */ + #[DataProvider('providerTestInvalidCloudAppUuidArg')] public function testInvalidCloudAppUuidArg(string $uuid, string $message): void { $this->expectException(ValidatorException::class); @@ -165,9 +163,7 @@ public static function providerTestInvalidCloudEnvironmentAlias(): array ]; } - /** - * @dataProvider providerTestInvalidCloudEnvironmentAlias - */ + #[DataProvider('providerTestInvalidCloudEnvironmentAlias')] public function testInvalidCloudEnvironmentAlias(string $alias, string $message): void { $this->expectException(ValidatorException::class); diff --git a/tests/phpunit/src/Commands/DocsCommandTest.php b/tests/phpunit/src/Commands/DocsCommandTest.php index 590f7752..eb496654 100644 --- a/tests/phpunit/src/Commands/DocsCommandTest.php +++ b/tests/phpunit/src/Commands/DocsCommandTest.php @@ -7,6 +7,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Command\DocsCommand; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\DataProvider; use Prophecy\Argument; /** @@ -19,9 +20,7 @@ protected function createCommand(): CommandBase return $this->injectCommand(DocsCommand::class); } - /** - * @dataProvider providerTestDocsCommand - */ + #[DataProvider('providerTestDocsCommand')] public function testDocsCommand(int $input, string $expectedOutput): void { $localMachineHelper = $this->mockLocalMachineHelper(); diff --git a/tests/phpunit/src/Commands/Env/EnvCreateCommandTest.php b/tests/phpunit/src/Commands/Env/EnvCreateCommandTest.php index 015aa225..fff897ae 100644 --- a/tests/phpunit/src/Commands/Env/EnvCreateCommandTest.php +++ b/tests/phpunit/src/Commands/Env/EnvCreateCommandTest.php @@ -8,6 +8,8 @@ use Acquia\Cli\Command\Env\EnvCreateCommand; use Acquia\Cli\Exception\AcquiaCliException; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; use Prophecy\Argument; /** @@ -102,10 +104,8 @@ public static function providerTestCreateCde(): array ]; } - /** - * @dataProvider providerTestCreateCde - * @group brokenProphecy - */ + #[DataProvider('providerTestCreateCde')] + #[Group('brokenProphecy')] public function testCreateCde(mixed $args, mixed $input): void { $domain = $this->setupCdeTest(self::$validLabel); @@ -124,9 +124,7 @@ public function testCreateCde(mixed $args, mixed $input): void $this->assertStringContainsString("Your CDE URL: $domain", $output); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testCreateCdeNonUniqueLabel(): void { $label = 'Dev'; @@ -143,9 +141,7 @@ public function testCreateCdeNonUniqueLabel(): void ); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testCreateCdeInvalidTag(): void { $this->setupCdeTest(self::$validLabel); @@ -161,9 +157,7 @@ public function testCreateCdeInvalidTag(): void ); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testCreateCdeApiFailure(): void { $this->setupCdeTest(self::$validLabel, false); diff --git a/tests/phpunit/src/Commands/Env/EnvDeleteCommandTest.php b/tests/phpunit/src/Commands/Env/EnvDeleteCommandTest.php index eb3d0e89..218c3127 100644 --- a/tests/phpunit/src/Commands/Env/EnvDeleteCommandTest.php +++ b/tests/phpunit/src/Commands/Env/EnvDeleteCommandTest.php @@ -8,6 +8,8 @@ use Acquia\Cli\Command\Env\EnvDeleteCommand; use Acquia\Cli\Exception\AcquiaCliException; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; /** * @property \Acquia\Cli\Command\Env\EnvDeleteCommand $command @@ -32,10 +34,8 @@ public static function providerTestDeleteCde(): array ]; } - /** - * @dataProvider providerTestDeleteCde - * @group brokenProphecy - */ + #[DataProvider('providerTestDeleteCde')] + #[Group('brokenProphecy')] public function testDeleteCde(mixed $environmentId): void { $applicationsResponse = $this->mockApplicationsRequest(); @@ -90,9 +90,7 @@ public function testDeleteCde(mixed $environmentId): void $this->assertStringContainsString("The $cde->label environment is being deleted", $output); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testNoExistingCDEEnvironment(): void { $applicationsResponse = $this->mockApplicationsRequest(); @@ -113,9 +111,7 @@ public function testNoExistingCDEEnvironment(): void ); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testNoEnvironmentArgumentPassed(): void { $applications = $this->mockRequest('getApplications'); diff --git a/tests/phpunit/src/Commands/Ide/IdeCreateCommandTest.php b/tests/phpunit/src/Commands/Ide/IdeCreateCommandTest.php index b0af2e4e..fa7a6720 100644 --- a/tests/phpunit/src/Commands/Ide/IdeCreateCommandTest.php +++ b/tests/phpunit/src/Commands/Ide/IdeCreateCommandTest.php @@ -9,6 +9,7 @@ use Acquia\Cli\Tests\CommandTestBase; use GuzzleHttp\Client; use GuzzleHttp\Psr7\Response; +use PHPUnit\Framework\Attributes\Group; use Prophecy\Prophecy\ObjectProphecy; /** @@ -38,9 +39,7 @@ protected function createCommand(): CommandBase ); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testCreate(): void { $applicationsResponse = $this->mockRequest('getApplications'); diff --git a/tests/phpunit/src/Commands/Ide/IdeInfoCommandTest.php b/tests/phpunit/src/Commands/Ide/IdeInfoCommandTest.php index 3c13937e..bdb618a2 100644 --- a/tests/phpunit/src/Commands/Ide/IdeInfoCommandTest.php +++ b/tests/phpunit/src/Commands/Ide/IdeInfoCommandTest.php @@ -7,6 +7,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Command\Ide\IdeInfoCommand; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\Group; /** * @property \Acquia\Cli\Command\Ide\IdeListCommand $command @@ -18,9 +19,7 @@ protected function createCommand(): CommandBase return $this->injectCommand(IdeInfoCommand::class); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testIdeInfoCommand(): void { $applications = $this->mockRequest('getApplications'); diff --git a/tests/phpunit/src/Commands/Ide/IdeListCommandTest.php b/tests/phpunit/src/Commands/Ide/IdeListCommandTest.php index 28e0909b..f169521b 100644 --- a/tests/phpunit/src/Commands/Ide/IdeListCommandTest.php +++ b/tests/phpunit/src/Commands/Ide/IdeListCommandTest.php @@ -7,6 +7,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Command\Ide\IdeListCommand; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\Group; /** * @property \Acquia\Cli\Command\Ide\IdeListCommand $command @@ -18,9 +19,7 @@ protected function createCommand(): CommandBase return $this->injectCommand(IdeListCommand::class); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testIdeListCommand(): void { $applications = $this->mockRequest('getApplications'); @@ -49,9 +48,7 @@ public function testIdeListCommand(): void $this->assertStringContainsString('IDE URL: https://feea197a-9503-4441-9f49-b4d420b0ecf8.ides.acquia.com', $output); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testIdeListEmptyCommand(): void { $this->mockRequest('getApplications'); diff --git a/tests/phpunit/src/Commands/Ide/IdeOpenCommandTest.php b/tests/phpunit/src/Commands/Ide/IdeOpenCommandTest.php index 4326d1c8..f4ddf170 100644 --- a/tests/phpunit/src/Commands/Ide/IdeOpenCommandTest.php +++ b/tests/phpunit/src/Commands/Ide/IdeOpenCommandTest.php @@ -7,6 +7,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Command\Ide\IdeOpenCommand; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\Group; /** * @property IdeOpenCommand $command @@ -18,9 +19,7 @@ protected function createCommand(): CommandBase return $this->injectCommand(IdeOpenCommand::class); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testIdeOpenCommand(): void { $applications = $this->mockRequest('getApplications'); diff --git a/tests/phpunit/src/Commands/Ide/IdePhpVersionCommandTest.php b/tests/phpunit/src/Commands/Ide/IdePhpVersionCommandTest.php index 4b9fe0ce..1dedd819 100644 --- a/tests/phpunit/src/Commands/Ide/IdePhpVersionCommandTest.php +++ b/tests/phpunit/src/Commands/Ide/IdePhpVersionCommandTest.php @@ -9,6 +9,7 @@ use Acquia\Cli\Exception\AcquiaCliException; use Acquia\Cli\Helpers\LocalMachineHelper; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\DataProvider; use Prophecy\Prophecy\ObjectProphecy; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Process\Process; @@ -38,9 +39,7 @@ public static function providerTestIdePhpVersionCommand(): array ]; } - /** - * @dataProvider providerTestIdePhpVersionCommand - */ + #[DataProvider('providerTestIdePhpVersionCommand')] public function testIdePhpVersionCommand(string $version): void { $localMachineHelper = $this->mockLocalMachineHelper(); @@ -73,9 +72,7 @@ public static function providerTestIdePhpVersionCommandFailure(): array ]; } - /** - * @dataProvider providerTestIdePhpVersionCommandFailure - */ + #[DataProvider('providerTestIdePhpVersionCommandFailure')] public function testIdePhpVersionCommandFailure(string $version, string $exceptionClass): void { $this->expectException($exceptionClass); diff --git a/tests/phpunit/src/Commands/Ide/IdeServiceRestartCommandTest.php b/tests/phpunit/src/Commands/Ide/IdeServiceRestartCommandTest.php index 4847608a..d39d021a 100644 --- a/tests/phpunit/src/Commands/Ide/IdeServiceRestartCommandTest.php +++ b/tests/phpunit/src/Commands/Ide/IdeServiceRestartCommandTest.php @@ -7,6 +7,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Command\Ide\IdeServiceRestartCommand; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\Validator\Exception\ValidatorException; /** @@ -33,9 +34,7 @@ public function testIdeServiceRestartCommand(): void $this->assertStringContainsString('Restarted php', $output); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testIdeServiceRestartCommandInvalid(): void { $localMachineHelper = $this->mockLocalMachineHelper(); diff --git a/tests/phpunit/src/Commands/Ide/IdeServiceStartCommandTest.php b/tests/phpunit/src/Commands/Ide/IdeServiceStartCommandTest.php index e6f68b1e..474138b5 100644 --- a/tests/phpunit/src/Commands/Ide/IdeServiceStartCommandTest.php +++ b/tests/phpunit/src/Commands/Ide/IdeServiceStartCommandTest.php @@ -7,6 +7,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Command\Ide\IdeServiceStartCommand; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\Validator\Exception\ValidatorException; /** @@ -33,9 +34,7 @@ public function testIdeServiceStartCommand(): void $this->assertStringContainsString('Starting php', $output); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testIdeServiceStartCommandInvalid(): void { $localMachineHelper = $this->mockLocalMachineHelper(); diff --git a/tests/phpunit/src/Commands/Ide/IdeServiceStopCommandTest.php b/tests/phpunit/src/Commands/Ide/IdeServiceStopCommandTest.php index 9f9c210d..94be5267 100644 --- a/tests/phpunit/src/Commands/Ide/IdeServiceStopCommandTest.php +++ b/tests/phpunit/src/Commands/Ide/IdeServiceStopCommandTest.php @@ -7,6 +7,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Command\Ide\IdeServiceStopCommand; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\Validator\Exception\ValidatorException; /** @@ -33,9 +34,7 @@ public function testIdeServiceStopCommand(): void $this->assertStringContainsString('Stopping php', $output); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testIdeServiceStopCommandInvalid(): void { $localMachineHelper = $this->mockLocalMachineHelper(); diff --git a/tests/phpunit/src/Commands/Ide/IdeXdebugToggleCommandTest.php b/tests/phpunit/src/Commands/Ide/IdeXdebugToggleCommandTest.php index 93d9e128..86c31dee 100644 --- a/tests/phpunit/src/Commands/Ide/IdeXdebugToggleCommandTest.php +++ b/tests/phpunit/src/Commands/Ide/IdeXdebugToggleCommandTest.php @@ -7,6 +7,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Command\Ide\IdeXdebugToggleCommand; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Component\Process\Process; /** @@ -55,9 +56,7 @@ public static function providerTestXdebugCommandEnable(): array ]; } - /** - * @dataProvider providerTestXdebugCommandEnable - */ + #[DataProvider('providerTestXdebugCommandEnable')] public function testXdebugCommandEnable(mixed $phpVersion): void { $this->setUpXdebug($phpVersion); @@ -69,9 +68,7 @@ public function testXdebugCommandEnable(mixed $phpVersion): void $this->assertStringContainsString("Xdebug PHP extension enabled", $this->getDisplay()); } - /** - * @dataProvider providerTestXdebugCommandEnable - */ + #[DataProvider('providerTestXdebugCommandEnable')] public function testXdebugCommandDisable(mixed $phpVersion): void { $this->setUpXdebug($phpVersion); diff --git a/tests/phpunit/src/Commands/Ide/Wizard/IdeWizardCreateSshKeyCommandTest.php b/tests/phpunit/src/Commands/Ide/Wizard/IdeWizardCreateSshKeyCommandTest.php index c19c4b07..7e573b92 100644 --- a/tests/phpunit/src/Commands/Ide/Wizard/IdeWizardCreateSshKeyCommandTest.php +++ b/tests/phpunit/src/Commands/Ide/Wizard/IdeWizardCreateSshKeyCommandTest.php @@ -7,12 +7,14 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Command\Ide\Wizard\IdeWizardCreateSshKeyCommand; use Acquia\Cli\Tests\Commands\Ide\IdeHelper; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\RequiresOperatingSystem; /** * @property \Acquia\Cli\Command\Ide\Wizard\IdeWizardCreateSshKeyCommand * $command - * @requires OS linux|darwin */ +#[RequiresOperatingSystem('linux|darwin')] class IdeWizardCreateSshKeyCommandTest extends IdeWizardTestBase { public function setUp(): void @@ -38,9 +40,7 @@ public function testCreate(): void $this->runTestCreate(); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testSshKeyAlreadyUploaded(): void { $this->runTestSshKeyAlreadyUploaded(); @@ -61,9 +61,7 @@ public function testPromptWaitForSshReturnsFalse(): void $this->runTestPromptWaitForSshReturnsFalse(); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testIdeWizardCreateSshKeyCommandHelpContainsIdeHelperText(): void { $help = $this->command->getHelp(); diff --git a/tests/phpunit/src/Commands/Pull/PullCodeCommandTest.php b/tests/phpunit/src/Commands/Pull/PullCodeCommandTest.php index 4e828769..793aca54 100644 --- a/tests/phpunit/src/Commands/Pull/PullCodeCommandTest.php +++ b/tests/phpunit/src/Commands/Pull/PullCodeCommandTest.php @@ -9,6 +9,7 @@ use Acquia\Cli\Command\Pull\PullCodeCommand; use Acquia\Cli\Tests\Commands\Ide\IdeHelper; use GuzzleHttp\Client; +use PHPUnit\Framework\Attributes\DataProvider; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Symfony\Component\Filesystem\Path; @@ -233,9 +234,7 @@ public static function providerTestMatchPhpVersion(): array ]; } - /** - * @dataProvider providerTestMatchPhpVersion - */ + #[DataProvider('providerTestMatchPhpVersion')] public function testMatchPhpVersion(string $phpVersion): void { IdeHelper::setCloudIdeEnvVars(); diff --git a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php index f61e3b80..613c8a64 100644 --- a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php +++ b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php @@ -14,6 +14,7 @@ use AcquiaCloudApi\Response\SiteInstanceDatabaseConnectionResponse; use AcquiaCloudApi\Response\SiteInstanceDatabaseResponse; use GuzzleHttp\Client; +use PHPUnit\Framework\Attributes\DataProvider; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Symfony\Component\Console\Output\BufferedOutput; @@ -308,9 +309,7 @@ public function testPullDatabaseWithMySqlImportError(): void ], self::inputChooseEnvironment()); } - /** - * @dataProvider providerTestPullDatabaseWithInvalidSslCertificate - */ + #[DataProvider('providerTestPullDatabaseWithInvalidSslCertificate')] public function testPullDatabaseWithInvalidSslCertificate(int $errorCode): void { $this->setupPullDatabase(true, false, false, true, false, $errorCode); diff --git a/tests/phpunit/src/Commands/Push/PushArtifactCommandTest.php b/tests/phpunit/src/Commands/Push/PushArtifactCommandTest.php index 4e2a1c3f..d5b738c1 100644 --- a/tests/phpunit/src/Commands/Push/PushArtifactCommandTest.php +++ b/tests/phpunit/src/Commands/Push/PushArtifactCommandTest.php @@ -7,6 +7,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Command\Push\PushArtifactCommand; use Acquia\Cli\Tests\Commands\Pull\PullCommandTestBase; +use PHPUnit\Framework\Attributes\DataProvider; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Symfony\Component\Console\Output\OutputInterface; @@ -51,9 +52,7 @@ public static function providerTestPushArtifact(): array ]; } - /** - * @dataProvider providerTestPushArtifact - */ + #[DataProvider('providerTestPushArtifact')] public function testPushArtifact(int $verbosity, bool $printOutput): void { $applications = $this->mockRequest('getApplications'); diff --git a/tests/phpunit/src/Commands/Push/PushDatabaseCommandTest.php b/tests/phpunit/src/Commands/Push/PushDatabaseCommandTest.php index 1c165058..23892e07 100644 --- a/tests/phpunit/src/Commands/Push/PushDatabaseCommandTest.php +++ b/tests/phpunit/src/Commands/Push/PushDatabaseCommandTest.php @@ -10,6 +10,7 @@ use Acquia\Cli\Helpers\LocalMachineHelper; use Acquia\Cli\Helpers\SshHelper; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\DataProvider; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Symfony\Component\Console\Output\OutputInterface; @@ -49,9 +50,7 @@ public function setUp(): void parent::setUp(); } - /** - * @dataProvider providerTestPushDatabase - */ + #[DataProvider('providerTestPushDatabase')] public function testPushDatabase(int $verbosity, bool $printOutput, bool $pv): void { $applications = $this->mockRequest('getApplications'); diff --git a/tests/phpunit/src/Commands/Remote/AliasesDownloadCommandTest.php b/tests/phpunit/src/Commands/Remote/AliasesDownloadCommandTest.php index e3f4b4b8..66da5b39 100644 --- a/tests/phpunit/src/Commands/Remote/AliasesDownloadCommandTest.php +++ b/tests/phpunit/src/Commands/Remote/AliasesDownloadCommandTest.php @@ -11,6 +11,8 @@ use GuzzleHttp\Psr7\Utils; use Phar; use PharData; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\RequiresOperatingSystem; use Symfony\Component\Filesystem\Path; /** @@ -47,8 +49,8 @@ public static function providerTestRemoteAliasesDownloadCommand(): array /** * @param bool $all Download aliases for all applications. - * @dataProvider providerTestRemoteAliasesDownloadCommand */ + #[DataProvider('providerTestRemoteAliasesDownloadCommand')] public function testRemoteAliasesDownloadCommand(array $inputs, array $args, ?string $destinationDir = null, bool $all = false): void { $aliasVersion = $inputs[0]; @@ -91,9 +93,7 @@ public function testRemoteAliasesDownloadCommand(array $inputs, array $args, ?st $this->assertStringContainsString('Cloud Platform Drush aliases installed into ' . $destinationDir, $output); } - /** - * @requires OS linux|darwin - */ + #[RequiresOperatingSystem('linux|darwin')] public function testRemoteAliasesDownloadFailed(): void { $drushAliasesFixture = Path::canonicalize(__DIR__ . '/../../../../fixtures/drush-aliases'); diff --git a/tests/phpunit/src/Commands/Remote/DrushCommandTest.php b/tests/phpunit/src/Commands/Remote/DrushCommandTest.php index 3ddaf3e2..3dca467a 100644 --- a/tests/phpunit/src/Commands/Remote/DrushCommandTest.php +++ b/tests/phpunit/src/Commands/Remote/DrushCommandTest.php @@ -7,6 +7,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Command\Remote\DrushCommand; use Acquia\Cli\Helpers\SshHelper; +use PHPUnit\Framework\Attributes\DataProvider; use Prophecy\Argument; /** @@ -38,9 +39,7 @@ public static function providerTestRemoteDrushCommand(): array ]; } - /** - * @dataProvider providerTestRemoteDrushCommand - */ + #[DataProvider('providerTestRemoteDrushCommand')] public function testRemoteDrushCommand(array $args): void { $this->mockGetEnvironment(); diff --git a/tests/phpunit/src/Commands/Remote/SshCommandTest.php b/tests/phpunit/src/Commands/Remote/SshCommandTest.php index d6d5f370..6010e3df 100644 --- a/tests/phpunit/src/Commands/Remote/SshCommandTest.php +++ b/tests/phpunit/src/Commands/Remote/SshCommandTest.php @@ -8,6 +8,7 @@ use Acquia\Cli\Command\Remote\SshCommand; use Acquia\Cli\Command\Self\ClearCacheCommand; use Acquia\Cli\Helpers\SshHelper; +use PHPUnit\Framework\Attributes\Group; use Prophecy\Argument; /** @@ -20,9 +21,7 @@ protected function createCommand(): CommandBase return $this->injectCommand(SshCommand::class); } - /** - * @group serial - */ + #[Group('serial')] public function testRemoteAliasesDownloadCommand(): void { ClearCacheCommand::clearCaches(); diff --git a/tests/phpunit/src/Commands/Self/ClearCacheCommandTest.php b/tests/phpunit/src/Commands/Self/ClearCacheCommandTest.php index 2cc9de10..a41e274d 100644 --- a/tests/phpunit/src/Commands/Self/ClearCacheCommandTest.php +++ b/tests/phpunit/src/Commands/Self/ClearCacheCommandTest.php @@ -8,6 +8,7 @@ use Acquia\Cli\Command\Ide\IdeListCommand; use Acquia\Cli\Command\Self\ClearCacheCommand; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\Group; /** * @property \Acquia\Cli\Command\App\UnlinkCommand $command @@ -19,9 +20,7 @@ protected function createCommand(): CommandBase return $this->injectCommand(ClearCacheCommand::class); } - /** - * @group serial - */ + #[Group('serial')] public function testAliasesAreCached(): void { ClearCacheCommand::clearCaches(); diff --git a/tests/phpunit/src/Commands/Self/TelemetryCommandTest.php b/tests/phpunit/src/Commands/Self/TelemetryCommandTest.php index 97c418e1..cd2e9a0a 100644 --- a/tests/phpunit/src/Commands/Self/TelemetryCommandTest.php +++ b/tests/phpunit/src/Commands/Self/TelemetryCommandTest.php @@ -11,6 +11,8 @@ use Acquia\Cli\Helpers\DataStoreContract; use Acquia\Cli\Tests\CommandTestBase; use AcquiaCloudApi\Connector\Connector; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; use Prophecy\Argument; use ReflectionClass; use Symfony\Component\Filesystem\Path; @@ -37,9 +39,7 @@ protected function createCommand(): CommandBase return $this->injectCommand(TelemetryCommand::class); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testTelemetryCommand(): void { $this->mockRequest('getAccount'); @@ -68,9 +68,9 @@ public static function providerTestTelemetryPrompt(): array /** * Tests telemetry prompt. * - * @dataProvider providerTestTelemetryPrompt * @param $message */ + #[DataProvider('providerTestTelemetryPrompt')] public function testTelemetryPrompt(array $inputs, mixed $message): void { $this->createMockCloudConfigFile([DataStoreContract::SEND_TELEMETRY => null]); diff --git a/tests/phpunit/src/Commands/Ssh/SshKeyCreateCommandTest.php b/tests/phpunit/src/Commands/Ssh/SshKeyCreateCommandTest.php index eb81dfdf..7df2aafd 100644 --- a/tests/phpunit/src/Commands/Ssh/SshKeyCreateCommandTest.php +++ b/tests/phpunit/src/Commands/Ssh/SshKeyCreateCommandTest.php @@ -7,6 +7,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Command\Ssh\SshKeyCreateCommand; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\DataProvider; use Prophecy\Argument; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Path; @@ -99,9 +100,7 @@ public static function providerTestCreate(): array ]; } - /** - * @dataProvider providerTestCreate - */ + #[DataProvider('providerTestCreate')] public function testCreate(mixed $sshAddSuccess, mixed $args, mixed $inputs): void { $sshKeyFilepath = Path::join($this->sshDir, '/' . self::$filename); diff --git a/tests/phpunit/src/Commands/Ssh/SshKeyUploadCommandTest.php b/tests/phpunit/src/Commands/Ssh/SshKeyUploadCommandTest.php index f5139616..e43721f3 100644 --- a/tests/phpunit/src/Commands/Ssh/SshKeyUploadCommandTest.php +++ b/tests/phpunit/src/Commands/Ssh/SshKeyUploadCommandTest.php @@ -8,6 +8,7 @@ use Acquia\Cli\Command\Ssh\SshKeyUploadCommand; use Acquia\Cli\Exception\AcquiaCliException; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\DataProvider; use Prophecy\Argument; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Path; @@ -63,9 +64,7 @@ public static function providerTestUpload(): array ]; } - /** - * @dataProvider providerTestUpload - */ + #[DataProvider('providerTestUpload')] public function testUpload(array $args, array $inputs, bool $perms): void { $sshKeysRequestBody = self::getMockRequestBodyFromSpec('/account/ssh-keys'); diff --git a/tests/phpunit/src/Misc/ChecklistTest.php b/tests/phpunit/src/Misc/ChecklistTest.php index 2e71dde5..c51b9462 100644 --- a/tests/phpunit/src/Misc/ChecklistTest.php +++ b/tests/phpunit/src/Misc/ChecklistTest.php @@ -6,6 +6,7 @@ use Acquia\Cli\Output\Checklist; use Acquia\Cli\Tests\TestBase; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\OutputInterface; @@ -23,9 +24,7 @@ public function setUp(): void $this->output = new ConsoleOutput(); } - /** - * @group serial - */ + #[Group('serial')] public function testSpinner(): void { putenv('PHPUNIT_RUNNING=1'); diff --git a/tests/phpunit/src/Misc/ExceptionListenerTest.php b/tests/phpunit/src/Misc/ExceptionListenerTest.php index bec130e4..ec86753b 100644 --- a/tests/phpunit/src/Misc/ExceptionListenerTest.php +++ b/tests/phpunit/src/Misc/ExceptionListenerTest.php @@ -10,6 +10,7 @@ use Acquia\Cli\Tests\TestBase; use AcquiaCloudApi\Exception\ApiErrorException; use League\OAuth2\Client\Provider\Exception\IdentityProviderException; +use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Event\ConsoleErrorEvent; use Symfony\Component\Console\Exception\RuntimeException; @@ -21,9 +22,7 @@ class ExceptionListenerTest extends TestBase private static string $appAliasHelp = 'The applicationUuid argument must be a valid UUID or unique application alias accessible to your Cloud Platform user.' . PHP_EOL . PHP_EOL . 'An alias consists of an application name optionally prefixed with a hosting realm, e.g. myapp or prod.myapp.' . PHP_EOL . PHP_EOL . 'Run acli remote:aliases:list to see a list of all available aliases.'; - /** - * @dataProvider providerTestHelp - */ + #[DataProvider('providerTestHelp')] public function testHelp(Throwable $error, string|array $helpText): void { $exceptionListener = new ExceptionListener(); diff --git a/tests/phpunit/src/Misc/LocalMachineHelperTest.php b/tests/phpunit/src/Misc/LocalMachineHelperTest.php index 0da8e886..5b848d1d 100644 --- a/tests/phpunit/src/Misc/LocalMachineHelperTest.php +++ b/tests/phpunit/src/Misc/LocalMachineHelperTest.php @@ -7,6 +7,8 @@ use Acquia\Cli\Exception\AcquiaCliException; use Acquia\Cli\Helpers\LocalMachineHelper; use Acquia\Cli\Tests\TestBase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Process\Process; @@ -14,10 +16,9 @@ * This test class must run serially because its tests have unavoidable side effects * on the environment (e.g., modifying environment variables like DISPLAY) and the filesystem * (e.g., setting up and tearing down fixtures). Running these tests in parallel could cause - * interference and unpredictable failures. Do not remove the @group serial annotation. - * - * @group serial + * interference and unpredictable failures. Do not remove the serial group attribute. */ +#[Group('serial')] class LocalMachineHelperTest extends TestBase { public function testStartBrowser(): void @@ -83,9 +84,7 @@ public static function providerTestExecuteFromCmd(): array ]; } - /** - * @dataProvider providerTestExecuteFromCmd() - */ + #[DataProvider('providerTestExecuteFromCmd')] public function testExecuteFromCmd(bool $interactive, bool|null $isTty, bool|null $printOutput): void { $localMachineHelper = $this->localMachineHelper; diff --git a/tests/phpunit/src/Misc/TelemetryHelperTest.php b/tests/phpunit/src/Misc/TelemetryHelperTest.php index a5711d75..d47fbe0f 100644 --- a/tests/phpunit/src/Misc/TelemetryHelperTest.php +++ b/tests/phpunit/src/Misc/TelemetryHelperTest.php @@ -6,6 +6,8 @@ use Acquia\Cli\Helpers\TelemetryHelper; use Acquia\Cli\Tests\TestBase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; class TelemetryHelperTest extends TestBase { @@ -52,10 +54,8 @@ public static function providerTestEnvironmentProvider(): array return $providersArray; } - /** - * @group serial - * @dataProvider providerTestEnvironmentProvider() - */ + #[DataProvider('providerTestEnvironmentProvider')] + #[Group('serial')] public function testEnvironmentProvider(string $provider, array $envVars): void { $this->unsetGitHubEnvVars(); @@ -66,9 +66,8 @@ public function testEnvironmentProvider(string $provider, array $envVars): void /** * Test the getEnvironmentProvider method when no environment provider is * detected. - * - * @group serial */ + #[Group('serial')] public function testGetEnvironmentProviderWithoutAnyEnvSet(): void { $this->unsetGitHubEnvVars(); @@ -96,13 +95,13 @@ public static function providerTestAhEnvNormalization(): array } /** - * @dataProvider providerTestAhEnvNormalization * @param string $ah_env * The Acquia hosting environment. * @param string $expected * The expected normalized environment. - * @group serial */ + #[DataProvider('providerTestAhEnvNormalization')] + #[Group('serial')] public function testAhEnvNormalization(string $ah_env, string $expected): void { $normalized_ah_env = TelemetryHelper::normalizeAhEnv($ah_env); @@ -133,10 +132,10 @@ public static function providerTestRedactSensitiveData(): array } /** - * @dataProvider providerTestRedactSensitiveData * @param array $data * @param array $expected */ + #[DataProvider('providerTestRedactSensitiveData')] public function testRedactSensitiveData(array $data, array $expected): void { $this->assertSame($expected, TelemetryHelper::redactSensitiveData($data)); @@ -155,9 +154,7 @@ public static function providerTestRedactSensitiveContext(): array ]; } - /** - * @dataProvider providerTestRedactSensitiveContext - */ + #[DataProvider('providerTestRedactSensitiveContext')] public function testRedactSensitiveContext(string $context, string $expected): void { $this->assertSame($expected, TelemetryHelper::redactSensitiveContext($context)); From ed7fbecff7d2af870a97edcaa5d029cc58205e6d Mon Sep 17 00:00:00 2001 From: Matthew Grasmick Date: Thu, 11 Jun 2026 08:50:59 -0400 Subject: [PATCH 10/17] Cache update checks for one day checkForNewVersion() previously hit the GitHub releases API on every command invocation, adding network latency to all commands and risking GitHub rate limits. Cache the result per installed version for 24 hours, and clear it via self:clear-caches. Co-Authored-By: Claude Fable 5 --- src/Command/CommandBase.php | 35 ++++++++++++++----- src/Command/Self/ClearCacheCommand.php | 1 + .../src/Commands/UpdateCommandTest.php | 26 +++++++++++--- 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/src/Command/CommandBase.php b/src/Command/CommandBase.php index 00c912a2..8f5dfa99 100644 --- a/src/Command/CommandBase.php +++ b/src/Command/CommandBase.php @@ -54,6 +54,7 @@ use AcquiaLogstream\LogstreamManager; use ArrayObject; use Closure; +use DateInterval; use Exception; use JsonException; use loophp\phposinfo\OsInfo; @@ -64,6 +65,8 @@ use Safe\Exceptions\FilesystemException; use SelfUpdate\SelfUpdateManager; use stdClass; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; +use Symfony\Component\Cache\CacheItem; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\FormatterHelper; use Symfony\Component\Console\Helper\ProgressBar; @@ -1595,6 +1598,14 @@ public static function getAliasCache(): AliasCache return new AliasCache('acli_aliases'); } + /** + * Return the cache used to throttle update checks. + */ + public static function getUpdateCheckCache(): FilesystemAdapter + { + return new FilesystemAdapter('acli_update_check'); + } + /** * @throws \Acquia\Cli\Exception\AcquiaCliException */ @@ -1681,17 +1692,25 @@ public function checkForNewVersion(): bool|string if (AcquiaDrupalEnvironmentDetector::isAhIdeEnv()) { return false; } - if ($this->getApplication()->getVersion() === 'dev-unknown') { + $version = $this->getApplication()->getVersion(); + if ($version === 'dev-unknown') { return false; } - try { - if (!$this->selfUpdateManager->isUpToDate()) { - return $this->selfUpdateManager->getLatestReleaseFromGithub()['tag_name']; + // Only hit the GitHub API once per day per installed version. + return self::getUpdateCheckCache()->get( + 'latest-version.' . str_replace(['{', '}', '(', ')', '/', '\\', '@', ':'], '_', $version), + function (CacheItem $item): bool|string { + $item->expiresAfter(new DateInterval('P1D')); + try { + if (!$this->selfUpdateManager->isUpToDate()) { + return $this->selfUpdateManager->getLatestReleaseFromGithub()['tag_name']; + } + } catch (Exception) { + $this->logger->debug("Could not determine if Acquia CLI has a new version available."); + } + return false; } - } catch (Exception) { - $this->logger->debug("Could not determine if Acquia CLI has a new version available."); - } - return false; + ); } /** diff --git a/src/Command/Self/ClearCacheCommand.php b/src/Command/Self/ClearCacheCommand.php index b984aab0..0b71c16b 100644 --- a/src/Command/Self/ClearCacheCommand.php +++ b/src/Command/Self/ClearCacheCommand.php @@ -33,6 +33,7 @@ public static function clearCaches(): void { $cache = self::getAliasCache(); $cache->clear(); + self::getUpdateCheckCache()->clear(); $systemCacheDir = Path::join(sys_get_temp_dir(), 'symphony-cache'); $fs = new Filesystem(); $fs->remove([$systemCacheDir]); diff --git a/tests/phpunit/src/Commands/UpdateCommandTest.php b/tests/phpunit/src/Commands/UpdateCommandTest.php index 454d5ec8..7dd009bd 100644 --- a/tests/phpunit/src/Commands/UpdateCommandTest.php +++ b/tests/phpunit/src/Commands/UpdateCommandTest.php @@ -28,6 +28,19 @@ public function testSelfUpdate(): void self::assertStringContainsString("Acquia CLI $this->endVersion is available", $this->getDisplay()); } + public function testUpdateCheckIsCached(): void + { + $this->application->setVersion($this->startVersion); + $this->mockSelfUpdateCommand(false, 1); + $this->executeCommand(); + self::assertStringContainsString("Acquia CLI $this->endVersion is available", $this->getDisplay()); + // The second run must use the cached result rather than hitting the + // GitHub API again; shouldBeCalledTimes(1) on the mock enforces it. + $this->executeCommand(); + self::assertEquals(0, $this->getStatusCode()); + self::assertStringContainsString("Acquia CLI $this->endVersion is available", $this->getDisplay()); + } + public function testBadResponseFailsSilently(): void { $this->application->setVersion($this->startVersion); @@ -40,15 +53,20 @@ public function testBadResponseFailsSilently(): void /** * @throws \GuzzleHttp\Exception\GuzzleException */ - private function mockSelfUpdateCommand(bool $exception = false): void + private function mockSelfUpdateCommand(bool $exception = false, ?int $expectedChecks = null): void { + CommandBase::getUpdateCheckCache()->clear(); $selfUpdateManagerProphecy = $this->prophet->prophesize(SelfUpdateManager::class); if ($exception) { $selfUpdateManagerProphecy->isUpToDate()->willThrow(new Exception())->shouldBeCalled(); } else { - $selfUpdateManagerProphecy->isUpToDate() - ->willReturn(false) - ->shouldBeCalled(); + $isUpToDate = $selfUpdateManagerProphecy->isUpToDate() + ->willReturn(false); + if ($expectedChecks === null) { + $isUpToDate->shouldBeCalled(); + } else { + $isUpToDate->shouldBeCalledTimes($expectedChecks); + } $selfUpdateManagerProphecy->getLatestReleaseFromGithub() ->willReturn(['tag_name' => $this->endVersion]) ->shouldBeCalled(); From 19d9aa62abfc99fcfa2b804b1cd6210e49c98b8d Mon Sep 17 00:00:00 2001 From: Matthew Grasmick Date: Thu, 11 Jun 2026 08:51:49 -0400 Subject: [PATCH 11/17] Add CLAUDE.md with build commands, architecture, and conventions Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..9b1d140d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,71 @@ +# Acquia CLI + +PHP 8.2+ Symfony Console application distributed as a PHAR (built with Box). +Commands talk to the Acquia Cloud Platform API and Acquia Cloud Site Factory +(ACSF) API. + +## Commands + +```shell +composer install # install dependencies +composer test # lint + cs + stan + unit +composer unit # full PHPUnit suite (serial group, then paratest) +composer cs # phpcs (acquia/coding-standards) +composer cbf # phpcbf autofix +composer stan # PHPStan (pass --memory-limit=2G if it OOMs) +composer mutation # Infection mutation testing +composer box-install && composer box-compile # build var/acli.phar +``` + +Run a single test class: + +```shell +vendor/bin/phpunit --filter SomeCommandTest tests/phpunit/src/Commands/... +``` + +GrumPHP runs phpcs as a pre-commit hook; commits fail on style violations. + +## Architecture + +- `bin/acli` boots `src/Kernel.php` (Symfony DI container, services wired in + `config/`). Commands are services in `src/Command/`. +- `src/Command/CommandBase.php` is the base for all commands: telemetry, + authentication, alias→UUID conversion, update checks. +- `api:*` and `acsf:*` commands are NOT hand-written: they are generated at + runtime by `src/Command/Api/ApiCommandHelper.php` from the OpenAPI specs in + `assets/acquia-spec.json` and `assets/acsf-spec.yaml`. To change an API + command, change the helper or regenerate the spec (`composer + update-cloud-api-spec`), not individual command classes. +- `src/DataStore/` persists config: `CloudDataStore` (`~/.acquia/cloud_api.conf`, + contains API credentials — must stay chmod 0600) and `AcquiaCliDatastore`. +- Telemetry goes to Amplitude and errors to Bugsnag via + `src/Helpers/TelemetryHelper.php`. Anything derived from command input must + pass through `TelemetryHelper::redactSensitiveData()` before being sent. + +## Conventions + +- TDD: write or update the failing PHPUnit test before changing `src/`. +- Tests use prophecy mocks; base classes `tests/phpunit/src/TestBase.php` and + `CommandTestBase.php` provide mock helpers for the Cloud API client + (`mockRequest()` reads from the OpenAPI spec fixtures). +- PHPUnit metadata uses PHP attributes (`#[Group]`, `#[DataProvider]`), not + doc-comment annotations. +- Tests that mutate global state belong in the `serial` group + (`#[Group('serial')]`); everything else runs under paratest. +- `declare(strict_types=1);` in every file; strict comparisons; phpcs enforces + ordered use-statement imports. +- Process execution: always pass argument arrays to + `LocalMachineHelper::execute()`; never interpolate user input into + `executeFromCmd()` shell strings. +- SSH/rsync/git invocations use `-o StrictHostKeyChecking=accept-new`; do not + weaken to `=no`. + +## Gotchas + +- Symfony is pinned to 6.4: `typhonius/acquia-logstream` blocks Symfony 7. +- PHP_CodeSniffer is pinned to 3.x: `acquia/coding-standards` and + `drupal/coder` block phpcs 4. +- `minimum-stability: dev` is required by the `consolidation/self-update` + fork pin; `prefer-stable: true` keeps everything else on stable releases. +- The update check in `CommandBase::checkForNewVersion()` is cached for 24h + (`self:clear-caches` clears it). From dad91ffbaf3a46302d6b65fc8a4dba50f468bf18 Mon Sep 17 00:00:00 2001 From: Matthew Grasmick Date: Thu, 11 Jun 2026 08:58:08 -0400 Subject: [PATCH 12/17] Address review findings in security and caching changes - Gate the Windows 'start' rewrite in startBrowser() behind an OS check so a literal 'start' browser on Linux/macOS is not rewritten to cmd.exe - Recurse into nested array values in redactSensitiveData() so sensitive keys inside array-typed arguments are also redacted - Sanitize the update-check cache key with an allowlist regex so version strings with reserved cache characters (e.g. 1.0.0+meta) cannot throw Co-Authored-By: Claude Fable 5 --- src/Command/CommandBase.php | 2 +- src/Helpers/LocalMachineHelper.php | 2 +- src/Helpers/TelemetryHelper.php | 2 ++ tests/phpunit/src/Misc/TelemetryHelperTest.php | 5 +++++ 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Command/CommandBase.php b/src/Command/CommandBase.php index 8f5dfa99..bc35ffd4 100644 --- a/src/Command/CommandBase.php +++ b/src/Command/CommandBase.php @@ -1698,7 +1698,7 @@ public function checkForNewVersion(): bool|string } // Only hit the GitHub API once per day per installed version. return self::getUpdateCheckCache()->get( - 'latest-version.' . str_replace(['{', '}', '(', ')', '/', '\\', '@', ':'], '_', $version), + 'latest-version.' . preg_replace('/[^A-Za-z0-9_.]/', '_', $version), function (CacheItem $item): bool|string { $item->expiresAfter(new DateInterval('P1D')); try { diff --git a/src/Helpers/LocalMachineHelper.php b/src/Helpers/LocalMachineHelper.php index d28085cd..c82c9210 100644 --- a/src/Helpers/LocalMachineHelper.php +++ b/src/Helpers/LocalMachineHelper.php @@ -429,7 +429,7 @@ public function startBrowser(?string $uri = null, ?string $browser = null): bool $cmd = array_values(array_filter(explode(' ', $browser), static function (string $part): bool { return $part !== ''; })); - if ($cmd[0] === 'start') { + if ($cmd[0] === 'start' && OsInfo::isWindows()) { // On Windows, `start` is a cmd.exe built-in rather than an // executable, and it treats the first quoted argument as a // window title, so pass an empty title before the URI. diff --git a/src/Helpers/TelemetryHelper.php b/src/Helpers/TelemetryHelper.php index 5fd96875..d3b84df1 100644 --- a/src/Helpers/TelemetryHelper.php +++ b/src/Helpers/TelemetryHelper.php @@ -77,6 +77,8 @@ public static function redactSensitiveData(array $data): array foreach ($data as $name => $value) { if ($value !== null && in_array($name, $sensitiveNames, true)) { $data[$name] = 'REDACTED'; + } elseif (is_array($value)) { + $data[$name] = self::redactSensitiveData($value); } } return $data; diff --git a/tests/phpunit/src/Misc/TelemetryHelperTest.php b/tests/phpunit/src/Misc/TelemetryHelperTest.php index d47fbe0f..bfe4c160 100644 --- a/tests/phpunit/src/Misc/TelemetryHelperTest.php +++ b/tests/phpunit/src/Misc/TelemetryHelperTest.php @@ -128,6 +128,11 @@ public static function providerTestRedactSensitiveData(): array ['filename' => 'id_rsa', 'password' => 'foo'], ['filename' => 'id_rsa', 'password' => 'REDACTED'], ], + // Sensitive keys nested in array values should be redacted. + [ + ['params' => ['password' => 'foo', 'filename' => 'id_rsa']], + ['params' => ['password' => 'REDACTED', 'filename' => 'id_rsa']], + ], ]; } From a744ef3e5a0b2989d4649c20a7f981f6c9d1e2c4 Mon Sep 17 00:00:00 2001 From: Matthew Grasmick Date: Thu, 11 Jun 2026 09:22:28 -0400 Subject: [PATCH 13/17] Fix Windows compatibility and restore 100% mutation coverage - Guard isTtyStream() with function_exists('posix_isatty') so the new test and the helper work on Windows, where the posix extension is absent - Strengthen ApiCommandHelper list-command tests to assert namespace, aliases, description, multi-namespace output, and continue-not-break iteration - Add a normal-verbosity git clone test pinning the verbosity comparison - Assert the update-check cache is cleared by self:clear-caches and that no upgrade message shows when the CLI is up to date - Ignore cache-TTL mutations (expiresAfter) in infection, which are not observable without manipulating the clock Co-Authored-By: Claude Fable 5 --- infection.json5 | 5 ++- src/Helpers/LocalMachineHelper.php | 3 ++ .../src/Commands/Api/ApiCommandHelperTest.php | 45 +++++++++++++++++++ .../src/Commands/Pull/PullCodeCommandTest.php | 36 ++++++++++++++- .../Commands/Self/ClearCacheCommandTest.php | 12 ++++- .../src/Commands/UpdateCommandTest.php | 11 +++++ 6 files changed, 107 insertions(+), 5 deletions(-) diff --git a/infection.json5 b/infection.json5 index 6c8aec23..d557b029 100644 --- a/infection.json5 +++ b/infection.json5 @@ -14,7 +14,10 @@ "mutators": { "@default": true, "global-ignoreSourceCodeByRegex": [ - "\\$this->logger.*" + "\\$this->logger.*", + // Cache TTLs only affect expiry timing, which is not observable in a + // unit test without manipulating the clock. + ".*->expiresAfter\\(.*" ] }, "timeout": 30, diff --git a/src/Helpers/LocalMachineHelper.php b/src/Helpers/LocalMachineHelper.php index c82c9210..44094146 100644 --- a/src/Helpers/LocalMachineHelper.php +++ b/src/Helpers/LocalMachineHelper.php @@ -136,6 +136,9 @@ private function configureProcess(Process $process, ?string $cwd = null, ?bool $ */ private function isTtyStream(mixed $fileDescriptor): bool { + if (!function_exists('posix_isatty')) { + return false; + } set_error_handler(static function (): bool { // Swallow the warning; posix_isatty() returns FALSE in this // case, correctly reporting the stream as not a TTY. diff --git a/tests/phpunit/src/Commands/Api/ApiCommandHelperTest.php b/tests/phpunit/src/Commands/Api/ApiCommandHelperTest.php index f939a00a..46f787c1 100644 --- a/tests/phpunit/src/Commands/Api/ApiCommandHelperTest.php +++ b/tests/phpunit/src/Commands/Api/ApiCommandHelperTest.php @@ -101,6 +101,51 @@ public function testNamespaceVisibleCommandAfterOtherNamespaceStillGetsListComma $this->assertArrayHasKey('api:mix', $listCommands, 'mix has a visible command after other namespace; break would skip it.'); } + /** + * A fully hidden namespace that sorts before visible namespaces must be + * skipped with `continue`, not `break`, so the later visible namespaces are + * still listed. Also asserts that every visible namespace is returned, not + * just the first. + */ + public function testHiddenNamespaceBeforeVisibleNamespacesListsAllVisible(): void + { + $apiCommands = [ + $this->createMockApiCommand('api:hidden:only', true), + $this->createMockApiCommand('api:alpha:create', false), + $this->createMockApiCommand('api:beta:create', false), + ]; + $listCommands = $this->generateApiListCommands($apiCommands); + $this->assertArrayNotHasKey('api:hidden', $listCommands); + // `break` instead of `continue` would stop at the hidden namespace and + // never reach alpha/beta. + $this->assertArrayHasKey('api:alpha', $listCommands); + // Returning only the first element would drop beta. + $this->assertArrayHasKey('api:beta', $listCommands); + $this->assertCount(2, $listCommands); + } + + /** + * The generated list command must have its name, namespace, (empty) aliases, + * and description set. + */ + public function testGeneratedListCommandHasExpectedProperties(): void + { + $apiCommands = [ + $this->createMockApiCommand('api:widgets:create', false), + ]; + $listCommands = $this->generateApiListCommands($apiCommands); + $command = $listCommands['api:widgets']; + $this->assertSame('api:widgets', $command->getName()); + $this->assertSame('List all API commands for the widgets resource', $command->getDescription()); + // createListCommand() returns an ApiListCommand whose AsCommand attribute + // declares aliases ['api']; the helper must clear them. + $this->assertSame([], $command->getAliases()); + $namespaceProperty = (new ReflectionMethod($command, 'setNamespace')) + ->getDeclaringClass() + ->getProperty('namespace'); + $this->assertSame('api:widgets', $namespaceProperty->getValue($command)); + } + /** * When every sub-command under a namespace is hidden, omit the namespace list command. */ diff --git a/tests/phpunit/src/Commands/Pull/PullCodeCommandTest.php b/tests/phpunit/src/Commands/Pull/PullCodeCommandTest.php index 793aca54..f042e5f3 100644 --- a/tests/phpunit/src/Commands/Pull/PullCodeCommandTest.php +++ b/tests/phpunit/src/Commands/Pull/PullCodeCommandTest.php @@ -12,6 +12,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; +use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Filesystem\Path; use Symfony\Component\Finder\Finder; @@ -74,6 +75,36 @@ public function testCloneRepo(): void ], $inputs); } + public function testCloneRepoQuietForwardsNoOutput(): void + { + // At normal verbosity the git clone must run with output forwarding + // disabled (printOutput=false). This pins the `>` verbosity comparison + // so it cannot be loosened to `>=`. + $this->acliRepoRoot = ''; + $this->command = $this->createCommand(); + $environment = $this->mockGetEnvironment(); + $localMachineHelper = $this->mockReadIdePhpVersion(); + $process = $this->mockProcess(); + $dir = Path::join($this->vfsRoot->url(), 'empty-dir-quiet'); + mkdir($dir); + $localMachineHelper->checkRequiredBinariesExist(["git"]) + ->shouldBeCalled(); + $this->mockExecuteGitClone($localMachineHelper, $environment, $process, $dir, false); + $this->mockExecuteGitCheckout($localMachineHelper, $environment->vcs->path, $dir, $process); + $localMachineHelper->getFinder()->willReturn(new Finder()); + + $inputs = [ + 'y', + self::$INPUT_DEFAULT_CHOICE, + 'n', + self::$INPUT_DEFAULT_CHOICE, + ]; + $this->executeCommand([ + '--dir' => $dir, + '--no-scripts' => true, + ], $inputs, OutputInterface::VERBOSITY_NORMAL); + } + public function testPullCode(): void { $environment = $this->mockGetEnvironment(); @@ -291,7 +322,8 @@ protected function mockExecuteGitClone( ObjectProphecy $localMachineHelper, object $environmentsResponse, ObjectProphecy $process, - mixed $dir + mixed $dir, + bool $printOutput = true ): void { $command = [ 'git', @@ -299,7 +331,7 @@ protected function mockExecuteGitClone( $environmentsResponse->vcs->url, $dir, ]; - $localMachineHelper->execute($command, Argument::type('callable'), null, true, null, ['GIT_SSH_COMMAND' => 'ssh -o StrictHostKeyChecking=accept-new']) + $localMachineHelper->execute($command, Argument::type('callable'), null, $printOutput, null, ['GIT_SSH_COMMAND' => 'ssh -o StrictHostKeyChecking=accept-new']) ->willReturn($process->reveal()) ->shouldBeCalled(); } diff --git a/tests/phpunit/src/Commands/Self/ClearCacheCommandTest.php b/tests/phpunit/src/Commands/Self/ClearCacheCommandTest.php index a41e274d..23ff014a 100644 --- a/tests/phpunit/src/Commands/Self/ClearCacheCommandTest.php +++ b/tests/phpunit/src/Commands/Self/ClearCacheCommandTest.php @@ -62,11 +62,19 @@ public function testAliasesAreCached(): void public function testClearCaches(): void { + // Seed both caches so we can prove each is cleared. + $aliasCache = CommandBase::getAliasCache(); + $aliasItem = $aliasCache->getItem('some-alias'); + $aliasCache->save($aliasItem->set('value')); + $updateCache = CommandBase::getUpdateCheckCache(); + $updateItem = $updateCache->getItem('latest-version.1_0_0'); + $updateCache->save($updateItem->set('2.0.0')); + $this->executeCommand(); $output = $this->getDisplay(); $this->assertStringContainsString('Acquia CLI caches were cleared', $output); - $cache = CommandBase::getAliasCache(); - $this->assertCount(0, iterator_to_array($cache->getItems(), false)); + $this->assertCount(0, iterator_to_array($aliasCache->getItems(), false)); + $this->assertFalse(CommandBase::getUpdateCheckCache()->getItem('latest-version.1_0_0')->isHit()); } } diff --git a/tests/phpunit/src/Commands/UpdateCommandTest.php b/tests/phpunit/src/Commands/UpdateCommandTest.php index 7dd009bd..6d864d00 100644 --- a/tests/phpunit/src/Commands/UpdateCommandTest.php +++ b/tests/phpunit/src/Commands/UpdateCommandTest.php @@ -41,6 +41,17 @@ public function testUpdateCheckIsCached(): void self::assertStringContainsString("Acquia CLI $this->endVersion is available", $this->getDisplay()); } + public function testNoUpdateMessageWhenUpToDate(): void + { + CommandBase::getUpdateCheckCache()->clear(); + $this->application->setVersion($this->startVersion); + // The default mock reports the CLI as up to date; no upgrade message + // should be shown (and checkForNewVersion() must return false, not true). + $this->executeCommand(); + self::assertEquals(0, $this->getStatusCode()); + self::assertStringNotContainsString('is available', $this->getDisplay()); + } + public function testBadResponseFailsSilently(): void { $this->application->setVersion($this->startVersion); From d8e6a7612f0f4fa4057b124c81ccdde7a31ede2b Mon Sep 17 00:00:00 2001 From: Matthew Grasmick Date: Thu, 11 Jun 2026 09:28:13 -0400 Subject: [PATCH 14/17] Skip Unix file-permission tests on Windows chmod() is a no-op on Windows, so the 0600/0700 assertions added for credential and SSH key hardening cannot hold there. Restrict those tests to linux|darwin, matching the existing convention for OS-specific tests. Co-Authored-By: Claude Fable 5 --- tests/phpunit/src/Commands/Ssh/SshKeyCreateCommandTest.php | 4 ++++ tests/phpunit/src/DataStore/JsonDataStoreTest.php | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/tests/phpunit/src/Commands/Ssh/SshKeyCreateCommandTest.php b/tests/phpunit/src/Commands/Ssh/SshKeyCreateCommandTest.php index 7df2aafd..13eda336 100644 --- a/tests/phpunit/src/Commands/Ssh/SshKeyCreateCommandTest.php +++ b/tests/phpunit/src/Commands/Ssh/SshKeyCreateCommandTest.php @@ -8,6 +8,7 @@ use Acquia\Cli\Command\Ssh\SshKeyCreateCommand; use Acquia\Cli\Tests\CommandTestBase; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\RequiresOperatingSystem; use Prophecy\Argument; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Path; @@ -132,7 +133,10 @@ public function testCreate(mixed $sshAddSuccess, mixed $args, mixed $inputs): vo * Test that restrictive permissions are enforced on the private key and * SSH directory after key generation, even if ssh-keygen (or whatever * created the files) left them too permissive. + * + * Unix file mode bits do not apply on Windows, where chmod() is a no-op. */ + #[RequiresOperatingSystem('linux|darwin')] public function testCreateEnforcesSecureKeyFilePermissions(): void { $privateKeyFilepath = Path::join($this->sshDir, self::$filename); diff --git a/tests/phpunit/src/DataStore/JsonDataStoreTest.php b/tests/phpunit/src/DataStore/JsonDataStoreTest.php index 8223f458..2d09c6c9 100644 --- a/tests/phpunit/src/DataStore/JsonDataStoreTest.php +++ b/tests/phpunit/src/DataStore/JsonDataStoreTest.php @@ -5,13 +5,17 @@ namespace Acquia\Cli\Tests\DataStore; use Acquia\Cli\DataStore\JsonDataStore; +use PHPUnit\Framework\Attributes\RequiresOperatingSystem; use PHPUnit\Framework\TestCase; use Symfony\Component\Filesystem\Filesystem; /** * Uses a real temp directory rather than vfsStream because vfsStream does not * faithfully emulate chmod() on all PHP versions. + * + * Unix file mode bits do not apply on Windows, where chmod() is a no-op. */ +#[RequiresOperatingSystem('linux|darwin')] class JsonDataStoreTest extends TestCase { private string $tempDir; From f98e98aa915645dc25e1283c17169012bd5d3db7 Mon Sep 17 00:00:00 2001 From: Matthew Grasmick Date: Thu, 11 Jun 2026 09:51:38 -0400 Subject: [PATCH 15/17] Lazily register API commands to avoid loading the full spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every acli invocation used to instantiate and fully configure all ~485 spec-derived api:* and acsf:* commands up front, and getApiCommands() loaded the entire ~1 MB Cloud API spec into memory to do it. Only one command ever runs. Register those commands through a Symfony FactoryCommandLoader instead: - Registration is driven by a lightweight per-spec manifest (command name, path/method, visibility flags) cached separately from the full spec — ~90 KB vs ~1 MB — so invoking a non-API command (the common case) no longer loads or parses the full spec at all. - Each command's full definition is built by a closure only when that command is actually requested. - The full spec load is memoized per process so building many commands (e.g. for 'list') parses it at most once. For a typical non-API command this cuts command registration from ~30 ms to ~10 ms and roughly halves peak memory (~33 MB to ~16 MB). 'acli list' output, per-command --help, and api:list are byte-for-byte unchanged. Co-Authored-By: Claude Fable 5 --- bin/acli | 9 +- src/Command/Api/ApiCommandHelper.php | 208 +++++++++++++++--- .../src/Commands/Api/ApiCommandHelperTest.php | 101 ++++++++- 3 files changed, 280 insertions(+), 38 deletions(-) diff --git a/bin/acli b/bin/acli index 15d54e06..4fb063b8 100755 --- a/bin/acli +++ b/bin/acli @@ -24,6 +24,7 @@ use Acquia\Cli\Helpers\LocalMachineHelper; use Dotenv\Dotenv; use SelfUpdate\SelfUpdateCommand; use SelfUpdate\SelfUpdateManager; +use Symfony\Component\Console\CommandLoader\FactoryCommandLoader; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Filesystem\Filesystem; @@ -97,8 +98,12 @@ $application = $container->get(Application::class); $output = $container->get(OutputInterface::class); /** @var ApiCommandHelper $helper */ $helper = $container->get(ApiCommandHelper::class); -$application->addCommands($helper->getApiCommands( __DIR__ . '/../assets/acquia-spec.json', 'api', $container->get(ApiCommandFactory::class))); -$application->addCommands($helper->getApiCommands( __DIR__ . '/../assets/acsf-spec.json', 'acsf', $container->get(AcsfCommandFactory::class))); +// Register the spec-derived commands lazily so a normal invocation only builds +// the single command being run, rather than all ~500 API/ACSF commands. +$application->setCommandLoader(new FactoryCommandLoader(array_merge( + $helper->getApiCommandFactories(__DIR__ . '/../assets/acquia-spec.json', 'api', $container->get(ApiCommandFactory::class)), + $helper->getApiCommandFactories(__DIR__ . '/../assets/acsf-spec.json', 'acsf', $container->get(AcsfCommandFactory::class)) +))); try { /** @var SelfUpdateManager $selfUpdateManager*/ $selfUpdateManager = $container->get(SelfUpdateManager::class); diff --git a/src/Command/Api/ApiCommandHelper.php b/src/Command/Api/ApiCommandHelper.php index 04594ed6..e00eced0 100644 --- a/src/Command/Api/ApiCommandHelper.php +++ b/src/Command/Api/ApiCommandHelper.php @@ -4,6 +4,7 @@ namespace Acquia\Cli\Command\Api; +use Acquia\Cli\Command\Acsf\AcsfListCommand; use Acquia\Cli\CommandFactoryInterface; use Acquia\Cli\Exception\AcquiaCliException; use Symfony\Component\Cache\Adapter\FilesystemAdapter; @@ -15,6 +16,11 @@ class ApiCommandHelper { + /** + * @var array> + */ + private array $loadedSpecs = []; + public function __construct( private ConsoleLogger $logger ) { @@ -339,6 +345,11 @@ private function isApiSpecChecksumCacheValid(\Symfony\Component\Cache\CacheItem */ private function getCloudApiSpec(string $specFilePath): array { + // Memoize within the process so building many commands (e.g. for the + // command list) loads the spec at most once. + if (isset($this->loadedSpecs[$specFilePath])) { + return $this->loadedSpecs[$specFilePath]; + } $cacheKey = basename($specFilePath); // Fall back to a filesystem cache so the parsed spec is still cached // between invocations when the warmed PHP cache file is absent. @@ -348,7 +359,7 @@ private function getCloudApiSpec(string $specFilePath): array // When running the phar, the original file may not exist. In that case, always use the cache. if (!file_exists($specFilePath) && $cacheItemSpec->isHit()) { - return $cacheItemSpec->get(); + return $this->loadedSpecs[$specFilePath] = $cacheItemSpec->get(); } // Otherwise, only use cache when it is valid. @@ -358,7 +369,7 @@ private function getCloudApiSpec(string $specFilePath): array $this->useCloudApiSpecCache() && $this->isApiSpecChecksumCacheValid($cacheItemChecksum, $checksum) && $cacheItemSpec->isHit() ) { - return $cacheItemSpec->get(); + return $this->loadedSpecs[$specFilePath] = $cacheItemSpec->get(); } // Parse file. This can take a long while! @@ -377,7 +388,7 @@ private function getCloudApiSpec(string $specFilePath): array $cacheKey . '.checksum' => $checksum, ]); - return $spec; + return $this->loadedSpecs[$specFilePath] = $spec; } /** @@ -397,35 +408,157 @@ private function generateApiCommandsFromSpec(array $acquiaCloudSpec, string $com continue; } - $commandName = $commandPrefix . ':' . $schema['x-cli-name']; - $command = $commandFactory->createCommand(); - $command->setName($commandName); - $command->setDescription($schema['summary']); - $command->setMethod($method); - $command->setResponses($schema['responses']); - $command->setHidden( - self::isDeprecated($schema) || self::isPreRelease($schema) - ); - if (array_key_exists('servers', $acquiaCloudSpec)) { - $command->setServers($acquiaCloudSpec['servers']); - } - $command->setPath($path); + $apiCommands[] = $this->buildApiCommand($path, $method, $schema, $acquiaCloudSpec, $commandPrefix, $commandFactory); + } + } - $helpText = "For more help, see https://cloudapi-docs.acquia.com/ or https://dev.acquia.com/api-documentation/acquia-cloud-site-factory-api for acsf commands."; - if (self::isPreRelease($schema)) { - $helpText .= "\n\nThis endpoint is pre-release and therefore unsupported and may be changed or removed without notice."; - } - if (self::isDeprecated($schema)) { - $helpText .= "\n\nThis endpoint is deprecated and may be removed without notice."; - } - $command->setHelp($helpText); + return $apiCommands; + } - $this->addApiCommandParameters($schema, $acquiaCloudSpec, $command); - $apiCommands[] = $command; + /** + * Build a single fully-configured API command from its spec entry. + */ + private function buildApiCommand(string $path, string $method, array $schema, array $acquiaCloudSpec, string $commandPrefix, CommandFactoryInterface $commandFactory): ApiBaseCommand + { + $command = $commandFactory->createCommand(); + $command->setName($commandPrefix . ':' . $schema['x-cli-name']); + $command->setDescription($schema['summary']); + $command->setMethod($method); + $command->setResponses($schema['responses']); + $command->setHidden( + self::isDeprecated($schema) || self::isPreRelease($schema) + ); + if (array_key_exists('servers', $acquiaCloudSpec)) { + $command->setServers($acquiaCloudSpec['servers']); + } + $command->setPath($path); + + $helpText = "For more help, see https://cloudapi-docs.acquia.com/ or https://dev.acquia.com/api-documentation/acquia-cloud-site-factory-api for acsf commands."; + if (self::isPreRelease($schema)) { + $helpText .= "\n\nThis endpoint is pre-release and therefore unsupported and may be changed or removed without notice."; + } + if (self::isDeprecated($schema)) { + $helpText .= "\n\nThis endpoint is deprecated and may be removed without notice."; + } + $command->setHelp($helpText); + + $this->addApiCommandParameters($schema, $acquiaCloudSpec, $command); + return $command; + } + + /** + * Build a lazy command-loader map for every spec-derived command. + * + * Each entry is a closure that fully configures one command only when it is + * actually requested. Registration is driven by a lightweight manifest + * (~30 KB) instead of the full ~1 MB spec, so invoking a non-API command + * (the common case) never loads or parses the full spec at all. + * + * @return array + */ + public function getApiCommandFactories(string $acquiaCloudSpecFilePath, string $commandPrefix, CommandFactoryInterface $commandFactory): array + { + $manifest = $this->getApiSpecManifest($acquiaCloudSpecFilePath); + $factories = []; + // Tracks whether each namespace has at least one visible command, so + // the namespace list commands match generateApiListCommands() exactly. + $namespaceHasVisibleCommand = []; + foreach ($manifest as $cliName => $entry) { + $name = $commandPrefix . ':' . $cliName; + $path = $entry['path']; + $method = $entry['method']; + $factories[$name] = function () use ($acquiaCloudSpecFilePath, $path, $method, $commandPrefix, $commandFactory): ApiBaseCommand { + $acquiaCloudSpec = $this->getCloudApiSpec($acquiaCloudSpecFilePath); + return $this->buildApiCommand($path, $method, $acquiaCloudSpec['paths'][$path][$method], $acquiaCloudSpec, $commandPrefix, $commandFactory); + }; + + $nameParts = explode(':', $name); + if (count($nameParts) < 3) { + continue; + } + $namespace = $nameParts[1]; + $namespaceHasVisibleCommand[$namespace] ??= false; + if (!$entry['deprecated'] && !$entry['prerelease']) { + $namespaceHasVisibleCommand[$namespace] = true; } } + foreach ($namespaceHasVisibleCommand as $namespace => $hasVisibleCommand) { + if (!$hasVisibleCommand) { + continue; + } + $listName = $commandPrefix . ':' . $namespace; + $factories[$listName] = fn (): ApiListCommand|AcsfListCommand => $this->buildApiListCommand($listName, $namespace, $commandFactory); + } + return $factories; + } - return $apiCommands; + /** + * Build (and cache) a lightweight manifest of the spec for command + * registration: command name, its path/method, and visibility flags. + * + * The manifest is stored in its own cache file, separate from the full + * spec, so loading it does not pull the full spec into memory. + * + * @return array + * @infection-ignore-all + */ + private function getApiSpecManifest(string $specFilePath): array + { + $cacheKey = basename($specFilePath) . '.manifest'; + $cache = new PhpArrayAdapter(__DIR__ . '/../../../var/cache/' . $cacheKey . '.cache', new FilesystemAdapter()); + $manifestItem = $cache->getItem($cacheKey); + $checksumItem = $cache->getItem($cacheKey . '.checksum'); + + // When running the phar, the original spec file may not exist; rely on + // the bundled cache. + if (!file_exists($specFilePath) && $manifestItem->isHit()) { + return $manifestItem->get(); + } + + $checksum = md5_file($specFilePath); + if ( + $this->useCloudApiSpecCache() + && $this->isApiSpecChecksumCacheValid($checksumItem, $checksum) + && $manifestItem->isHit() + ) { + return $manifestItem->get(); + } + + $manifest = $this->buildApiSpecManifest($this->getCloudApiSpec($specFilePath)); + $cache->warmUp([ + $cacheKey => $manifest, + $cacheKey . '.checksum' => $checksum, + ]); + return $manifest; + } + + /** + * Reduce a full spec to the manifest entries used for command registration. + * + * @param array $acquiaCloudSpec + * @return array + */ + private function buildApiSpecManifest(array $acquiaCloudSpec): array + { + $skippedApiCommands = $this->getSkippedApiCommands(); + $manifest = []; + foreach ($acquiaCloudSpec['paths'] as $path => $endpoint) { + foreach ($endpoint as $method => $schema) { + if (!array_key_exists('x-cli-name', $schema)) { + continue; + } + if (in_array($schema['x-cli-name'], $skippedApiCommands, true)) { + continue; + } + $manifest[$schema['x-cli-name']] = [ + 'deprecated' => self::isDeprecated($schema), + 'method' => $method, + 'path' => $path, + 'prerelease' => self::isPreRelease($schema), + ]; + } + } + return $manifest; } /** @@ -591,17 +724,24 @@ private function generateApiListCommands(array $apiCommands, string $commandPref continue; } $name = $commandPrefix . ':' . $namespace; - /** @var \Acquia\Cli\Command\Acsf\AcsfListCommand|\Acquia\Cli\Command\Api\ApiListCommand $command */ - $command = $commandFactory->createListCommand(); - $command->setName($name); - $command->setNamespace($name); - $command->setAliases([]); - $command->setDescription("List all API commands for the $namespace resource"); - $apiListCommands[$name] = $command; + $apiListCommands[$name] = $this->buildApiListCommand($name, $namespace, $commandFactory); } return $apiListCommands; } + /** + * Build a single namespace list command (e.g. api:accounts). + */ + private function buildApiListCommand(string $name, string $namespace, CommandFactoryInterface $commandFactory): ApiListCommand|AcsfListCommand + { + $command = $commandFactory->createListCommand(); + $command->setName($name); + $command->setNamespace($name); + $command->setAliases([]); + $command->setDescription("List all API commands for the $namespace resource"); + return $command; + } + /** * @param array $requestBody * @return array diff --git a/tests/phpunit/src/Commands/Api/ApiCommandHelperTest.php b/tests/phpunit/src/Commands/Api/ApiCommandHelperTest.php index 46f787c1..75fd7de7 100644 --- a/tests/phpunit/src/Commands/Api/ApiCommandHelperTest.php +++ b/tests/phpunit/src/Commands/Api/ApiCommandHelperTest.php @@ -183,10 +183,13 @@ public function testGetCloudApiSpecUsesFallbackCacheWhenWarmedCacheFileIsMissing $this->assertStringContainsString('Rebuilding caches', $output->fetch()); // Even with the warmed cache file gone, the fallback cache must - // serve the parsed spec rather than re-parsing the spec file. + // serve the parsed spec rather than re-parsing the spec file. Use a + // fresh helper so the per-process memoization does not short-circuit + // the fallback-cache lookup under test. unlink($warmedCacheFilePath); $this->resetPhpArrayAdapterStaticCache(); - $secondSpec = $method->invoke($helper, $specFilePath); + $freshHelper = new ApiCommandHelper(new ConsoleLogger($output)); + $secondSpec = $method->invoke($freshHelper, $specFilePath); $this->assertSame($firstSpec, $secondSpec); $this->assertStringNotContainsString('Rebuilding caches', $output->fetch()); } finally { @@ -224,6 +227,100 @@ private function invokeApiCommandHelperMethod(string $methodName, array $args = return $refClass->invokeArgs($commandHelper, $args); } + /** + * The lazy factory map must register exactly the same set of commands as the + * eager getApiCommands(), so switching to lazy registration changes nothing + * a user can observe. + */ + public function testGetApiCommandFactoriesMatchEagerCommands(): void + { + $helper = new ApiCommandHelper($this->logger); + // getApiCommands() returns a flat list that may repeat a name; Symfony + // dedupes on registration, as does the keyed factory map, so compare the + // unique name sets. + $eagerNames = array_unique(array_map(static fn ($c) => $c->getName(), $helper->getApiCommands(self::$apiSpecFixtureFilePath, 'api', $this->getCommandFactory()))); + sort($eagerNames); + + $factories = $helper->getApiCommandFactories(self::$apiSpecFixtureFilePath, 'api', $this->getCommandFactory()); + $lazyNames = array_keys($factories); + sort($lazyNames); + + $this->assertSame(array_values($eagerNames), $lazyNames); + // Every entry must be a closure that is only invoked on demand. + foreach ($factories as $factory) { + $this->assertInstanceOf(\Closure::class, $factory); + } + // Skipped commands are never registered; namespace list commands are. + $this->assertArrayNotHasKey('api:ssh-key:list', $factories); + $this->assertArrayHasKey('api:accounts', $factories); + } + + /** + * Invoking a factory must lazily build a command identical to the one the + * eager path produces (name, description, and input definition). + */ + public function testGetApiCommandFactoriesBuildConfiguredCommandOnDemand(): void + { + $helper = new ApiCommandHelper($this->logger); + $name = 'api:accounts:find'; + $eager = $helper->getApiCommands(self::$apiSpecFixtureFilePath, 'api', $this->getCommandFactory()); + $eagerCommand = null; + foreach ($eager as $candidate) { + if ($candidate->getName() === $name) { + $eagerCommand = $candidate; + break; + } + } + $this->assertNotNull($eagerCommand); + + $factories = $helper->getApiCommandFactories(self::$apiSpecFixtureFilePath, 'api', $this->getCommandFactory()); + $this->assertArrayHasKey($name, $factories); + $lazyCommand = $factories[$name](); + + $this->assertSame($eagerCommand->getName(), $lazyCommand->getName()); + $this->assertSame($eagerCommand->getDescription(), $lazyCommand->getDescription()); + $this->assertNotEmpty($lazyCommand->getDescription()); + $this->assertSame( + array_keys($eagerCommand->getDefinition()->getOptions()), + array_keys($lazyCommand->getDefinition()->getOptions()) + ); + $this->assertSame( + array_keys($eagerCommand->getDefinition()->getArguments()), + array_keys($lazyCommand->getDefinition()->getArguments()) + ); + } + + /** + * A built command must carry its responses, servers, and the right help + * text for normal, pre-release, and deprecated endpoints. + */ + public function testBuildApiCommandConfiguresResponsesServersAndHelp(): void + { + $normal = $this->getApiCommandByName('api:accounts:find'); + $this->assertNotNull($normal); + $this->assertNotEmpty($this->readProtected($normal, 'responses')); + $this->assertNotEmpty($this->readProtected($normal, 'servers')); + $this->assertStringContainsString('For more help', $normal->getHelp()); + $this->assertStringNotContainsString('pre-release', $normal->getHelp()); + $this->assertStringNotContainsString('deprecated and may be removed', $normal->getHelp()); + + $preRelease = $this->getApiCommandByName('api:codebases:applications:list'); + $this->assertNotNull($preRelease); + // The notice must be appended to (not replace) the base help text. + $this->assertStringContainsString('For more help', $preRelease->getHelp()); + $this->assertStringContainsString('pre-release', $preRelease->getHelp()); + + $deprecated = $this->getApiCommandByName('api:applications:hosting-settings-list'); + $this->assertNotNull($deprecated); + $this->assertStringContainsString('For more help', $deprecated->getHelp()); + $this->assertStringContainsString('deprecated and may be removed', $deprecated->getHelp()); + } + + private function readProtected(object $object, string $property): mixed + { + return (new \ReflectionProperty($object, $property))->getValue($object); + } + /** * Test that addPostArgumentUsageToExample correctly formats a flat array with a single item. */ From e8f566784bc89c6f8590ae1bcf0647b52a575369 Mon Sep 17 00:00:00 2001 From: Matthew Grasmick Date: Thu, 11 Jun 2026 10:03:21 -0400 Subject: [PATCH 16/17] Make manifest mutation coverage deterministic The new manifest-building logic is served from a checksum-keyed cache, so its covering tests passed against a manifest built by unmutated code, letting mutations in buildApiSpecManifest survive on CI (where the cache was warm). Bypass the spec cache in the factory tests so the manifest is rebuilt from source under test, and add a direct buildApiSpecManifest test with a crafted spec that pins the continue-not-break behavior for ignored methods. Co-Authored-By: Claude Fable 5 --- .../src/Commands/Api/ApiCommandHelperTest.php | 119 ++++++++++++------ 1 file changed, 79 insertions(+), 40 deletions(-) diff --git a/tests/phpunit/src/Commands/Api/ApiCommandHelperTest.php b/tests/phpunit/src/Commands/Api/ApiCommandHelperTest.php index 75fd7de7..9a3f8763 100644 --- a/tests/phpunit/src/Commands/Api/ApiCommandHelperTest.php +++ b/tests/phpunit/src/Commands/Api/ApiCommandHelperTest.php @@ -234,25 +234,32 @@ private function invokeApiCommandHelperMethod(string $methodName, array $args = */ public function testGetApiCommandFactoriesMatchEagerCommands(): void { - $helper = new ApiCommandHelper($this->logger); - // getApiCommands() returns a flat list that may repeat a name; Symfony - // dedupes on registration, as does the keyed factory map, so compare the - // unique name sets. - $eagerNames = array_unique(array_map(static fn ($c) => $c->getName(), $helper->getApiCommands(self::$apiSpecFixtureFilePath, 'api', $this->getCommandFactory()))); - sort($eagerNames); + // Build the registration manifest from source rather than the cache, so + // the manifest-building logic is actually exercised under test. + putenv('ACQUIA_CLI_USE_CLOUD_API_SPEC_CACHE=0'); + try { + $helper = new ApiCommandHelper($this->logger); + // getApiCommands() returns a flat list that may repeat a name; + // Symfony dedupes on registration, as does the keyed factory map, so + // compare the unique name sets. + $eagerNames = array_unique(array_map(static fn ($c) => $c->getName(), $helper->getApiCommands(self::$apiSpecFixtureFilePath, 'api', $this->getCommandFactory()))); + sort($eagerNames); - $factories = $helper->getApiCommandFactories(self::$apiSpecFixtureFilePath, 'api', $this->getCommandFactory()); - $lazyNames = array_keys($factories); - sort($lazyNames); + $factories = $helper->getApiCommandFactories(self::$apiSpecFixtureFilePath, 'api', $this->getCommandFactory()); + $lazyNames = array_keys($factories); + sort($lazyNames); - $this->assertSame(array_values($eagerNames), $lazyNames); - // Every entry must be a closure that is only invoked on demand. - foreach ($factories as $factory) { - $this->assertInstanceOf(\Closure::class, $factory); + $this->assertSame(array_values($eagerNames), $lazyNames); + // Every entry must be a closure that is only invoked on demand. + foreach ($factories as $factory) { + $this->assertInstanceOf(\Closure::class, $factory); + } + // Skipped commands are never registered; namespace list commands are. + $this->assertArrayNotHasKey('api:ssh-key:list', $factories); + $this->assertArrayHasKey('api:accounts', $factories); + } finally { + putenv('ACQUIA_CLI_USE_CLOUD_API_SPEC_CACHE'); } - // Skipped commands are never registered; namespace list commands are. - $this->assertArrayNotHasKey('api:ssh-key:list', $factories); - $this->assertArrayHasKey('api:accounts', $factories); } /** @@ -261,33 +268,38 @@ public function testGetApiCommandFactoriesMatchEagerCommands(): void */ public function testGetApiCommandFactoriesBuildConfiguredCommandOnDemand(): void { - $helper = new ApiCommandHelper($this->logger); - $name = 'api:accounts:find'; - $eager = $helper->getApiCommands(self::$apiSpecFixtureFilePath, 'api', $this->getCommandFactory()); - $eagerCommand = null; - foreach ($eager as $candidate) { - if ($candidate->getName() === $name) { - $eagerCommand = $candidate; - break; + putenv('ACQUIA_CLI_USE_CLOUD_API_SPEC_CACHE=0'); + try { + $helper = new ApiCommandHelper($this->logger); + $name = 'api:accounts:find'; + $eager = $helper->getApiCommands(self::$apiSpecFixtureFilePath, 'api', $this->getCommandFactory()); + $eagerCommand = null; + foreach ($eager as $candidate) { + if ($candidate->getName() === $name) { + $eagerCommand = $candidate; + break; + } } - } - $this->assertNotNull($eagerCommand); + $this->assertNotNull($eagerCommand); - $factories = $helper->getApiCommandFactories(self::$apiSpecFixtureFilePath, 'api', $this->getCommandFactory()); - $this->assertArrayHasKey($name, $factories); - $lazyCommand = $factories[$name](); + $factories = $helper->getApiCommandFactories(self::$apiSpecFixtureFilePath, 'api', $this->getCommandFactory()); + $this->assertArrayHasKey($name, $factories); + $lazyCommand = $factories[$name](); - $this->assertSame($eagerCommand->getName(), $lazyCommand->getName()); - $this->assertSame($eagerCommand->getDescription(), $lazyCommand->getDescription()); - $this->assertNotEmpty($lazyCommand->getDescription()); - $this->assertSame( - array_keys($eagerCommand->getDefinition()->getOptions()), - array_keys($lazyCommand->getDefinition()->getOptions()) - ); - $this->assertSame( - array_keys($eagerCommand->getDefinition()->getArguments()), - array_keys($lazyCommand->getDefinition()->getArguments()) - ); + $this->assertSame($eagerCommand->getName(), $lazyCommand->getName()); + $this->assertSame($eagerCommand->getDescription(), $lazyCommand->getDescription()); + $this->assertNotEmpty($lazyCommand->getDescription()); + $this->assertSame( + array_keys($eagerCommand->getDefinition()->getOptions()), + array_keys($lazyCommand->getDefinition()->getOptions()) + ); + $this->assertSame( + array_keys($eagerCommand->getDefinition()->getArguments()), + array_keys($lazyCommand->getDefinition()->getArguments()) + ); + } finally { + putenv('ACQUIA_CLI_USE_CLOUD_API_SPEC_CACHE'); + } } /** @@ -321,6 +333,33 @@ private function readProtected(object $object, string $property): mixed return (new \ReflectionProperty($object, $property))->getValue($object); } + /** + * The manifest builder must skip (continue past), not break out of, both + * methods that lack an x-cli-name and methods whose command is skipped, so + * that a later valid method on the same path is still registered. + */ + public function testBuildApiSpecManifestContinuesPastIgnoredMethods(): void + { + $spec = [ + 'paths' => [ + // 'get' has no x-cli-name and must be skipped without dropping 'post'. + '/a' => [ + 'get' => ['responses' => []], + 'post' => ['x-cli-name' => 'a:create', 'summary' => 's', 'responses' => []], + ], + // 'get' is an explicitly skipped command and must not drop 'post'. + '/b' => [ + 'get' => ['x-cli-name' => 'ide:create', 'summary' => 's', 'responses' => []], + 'post' => ['x-cli-name' => 'b:create', 'summary' => 's', 'responses' => []], + ], + ], + ]; + $manifest = $this->invokeApiCommandHelperMethod('buildApiSpecManifest', [$spec]); + $this->assertArrayHasKey('a:create', $manifest); + $this->assertArrayHasKey('b:create', $manifest); + $this->assertArrayNotHasKey('ide:create', $manifest); + } + /** * Test that addPostArgumentUsageToExample correctly formats a flat array with a single item. */ From d8fd8d4a53c5c0709177d67a2f8a58d649736080 Mon Sep 17 00:00:00 2001 From: Deepak Kumar Mishra Date: Wed, 17 Jun 2026 14:25:40 +0530 Subject: [PATCH 17/17] test fix. --- tests/phpunit/src/Commands/Push/PushDatabaseCommandTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpunit/src/Commands/Push/PushDatabaseCommandTest.php b/tests/phpunit/src/Commands/Push/PushDatabaseCommandTest.php index 6fe75984..d25fdae9 100644 --- a/tests/phpunit/src/Commands/Push/PushDatabaseCommandTest.php +++ b/tests/phpunit/src/Commands/Push/PushDatabaseCommandTest.php @@ -268,7 +268,7 @@ private function mockImportDatabaseDumpOnRemoteWithSpecialChars(ObjectProphecy|L 0 => 'ssh', 1 => 'profserv2.01dev@profserv201dev.ssh.enterprise-g1.acquia-sites.com', 2 => '-t', - 3 => '-o StrictHostKeyChecking=no', + 3 => '-o StrictHostKeyChecking=accept-new', 4 => '-o AddressFamily inet', 5 => '-o LogLevel=ERROR', 6 => "bash -o pipefail -c 'pv '/mnt/tmp/profserv2.01dev/acli-mysql-dump-drupal.sql.gz' --bytes --rate | gunzip | MYSQL_PWD='pass'\\''word' mysql --host='db'\\''host.enterprise-g1.hosting.acquia.com' --user='user'\\''name' 'db'\\''name''",