diff --git a/.github/pie-behaviour-tests/Dockerfile b/.github/pie-behaviour-tests/Dockerfile new file mode 100644 index 00000000..529d5bdc --- /dev/null +++ b/.github/pie-behaviour-tests/Dockerfile @@ -0,0 +1,62 @@ +# An approximately reproducible, but primarily isolated, environment for +# running the acceptance tests: +# +# GITHUB_TOKEN=$(composer config --global --auth github-oauth.github.com) docker buildx build --file .github/pie-behaviour-tests/Dockerfile --secret id=GITHUB_TOKEN,env=GITHUB_TOKEN -t pie-behat-test . +# docker run --volume .:/github/workspace -ti pie-behat-test +FROM alpine/git:v2.49.1 AS clone_ext_repo + +RUN cd / && git clone https://github.com/asgrim/example-pie-extension.git + +FROM boxproject/box:4.6.10 AS build_pie_phar + +RUN apk add git +COPY . /app +RUN cd /app && touch creating_this_means_phar_will_never_be_verified && /box.phar compile + +FROM ubuntu:24.04 AS default + +# Add the `unzip` package which PIE uses to extract .zip files +RUN export DEBIAN_FRONTEND="noninteractive"; \ + set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + unzip curl jq wget g++ gcc make autoconf libtool bison re2c pkg-config \ + ca-certificates libxml2-dev libssl-dev; \ + update-ca-certificates ; \ + rm -rf /var/lib/apt/lists/* + +# Compile PHP +ARG PHP_VERSION=8.4 +RUN mkdir -p /usr/local/src/php; \ + cd /usr/local/src/php; \ + FULL_LATEST_VERSION=`curl -fsSL "https://www.php.net/releases/index.php?json&max=1&version=$PHP_VERSION" | jq -r '.[].source[]|select(.filename|endswith(".gz")).filename'`; \ + wget -O php.tgz "https://www.php.net/distributions/$FULL_LATEST_VERSION" ; \ + tar zxf php.tgz ; \ + rm php.tgz ; \ + ls -l ; \ + cd * ; \ + ls -l ; \ + ./buildconf --force ; \ + ./configure --without-sqlite3 --disable-pdo --disable-dom --disable-xml --disable-xmlreader --disable-xmlwriter --disable-json --with-openssl ; \ + make -j$(nproc) ; \ + make install + +RUN touch /usr/local/lib/php.ini + +COPY --from=ghcr.io/php/pie:bin /pie /usr/local/bin/pie +RUN pie install xdebug/xdebug + +COPY --from=clone_ext_repo /example-pie-extension /example-pie-extension + +WORKDIR /github/workspace + +COPY --from=composer /usr/bin/composer /usr/bin/composer +RUN --mount=type=secret,id=GITHUB_TOKEN,env=GITHUB_TOKEN \ + composer config --global --auth github-oauth.github.com $GITHUB_TOKEN + +COPY --from=build_pie_phar /app/pie.phar /usr/local/bin/pie +COPY --from=build_pie_phar /app/pie.phar /usr/local/bin/pie.original + +ENV USING_PIE_BEHAT_DOCKERFILE=1 +ENTRYPOINT ["php", "vendor/bin/behat"] +CMD ["--no-snippets"] diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 0b8e919d..31b1690b 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -138,29 +138,28 @@ jobs: matrix: operating-system: - ubuntu-latest - - windows-latest php-versions: - '8.1' - '8.2' - '8.3' - '8.4' - - '8.5' steps: - - name: Setup PHP - uses: shivammathur/setup-php@v2 + - uses: actions/checkout@v5 with: - php-version: ${{ matrix.php-versions }} - extensions: intl, sodium, zip + fetch-depth: 0 + # Fixes `git describe` picking the wrong tag - see https://github.com/php/pie/issues/307 + - run: git fetch --tags --force + # Ensure some kind of previous tag exists, otherwise box fails + - run: git describe --tags HEAD || git tag 0.0.0 + - uses: ramsey/composer-install@v3 + - name: Build + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: docker buildx build --file .github/pie-behaviour-tests/Dockerfile --secret id=GITHUB_TOKEN,env=GITHUB_TOKEN --build-arg PHP_VERSION=${{ matrix.php-versions }} -t pie-behat-test . + - name: Run Behat + run: docker run --volume .:/github/workspace pie-behat-test env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - uses: actions/checkout@v5 - - uses: ramsey/composer-install@v3 - - name: Run Behat on Windows - if: matrix.operating-system == 'windows-latest' - run: vendor/bin/behat --tags="~@non-windows" - - name: Run Behat on non-Windows - if: matrix.operating-system != 'windows-latest' - run: sudo vendor/bin/behat coding-standards: runs-on: ubuntu-latest diff --git a/behat.php b/behat.php index 8202fe0a..c5fa1a54 100644 --- a/behat.php +++ b/behat.php @@ -8,6 +8,25 @@ use Behat\Config\Suite; use Php\PieBehaviourTest\CliContext; +if (getenv('USING_PIE_BEHAT_DOCKERFILE') !== '1') { + echo <<<'HELP' +⚠️ ⚠️ ⚠️ STOP! ⚠️ ⚠️ ⚠️ + +This test suite tinkers with your system, and has lots of expectations about +the system it is running on, so we HIGHLY recommend you run it using the +provided Dockerfile: + + docker buildx build --file .github/actions/pie-behaviour-tests/Dockerfile -t pie-behat-test . + docker run --volume .:/github/workspace -ti pie-behat-test + +If you are really sure, and accept that the test suite installs/uninstalls +stuff from your system, and might break your stuff, set +USING_PIE_BEHAT_DOCKERFILE=1 in your environment. + +HELP; + exit(1); +} + $profile = (new Profile('default')) ->withSuite( (new Suite('default')) diff --git a/features/bundled-php-extensions.feature b/features/bundled-php-extensions.feature new file mode 100644 index 00000000..2a6f0d50 --- /dev/null +++ b/features/bundled-php-extensions.feature @@ -0,0 +1,12 @@ +Feature: Bundled PHP extensions can be installed + + # pie install php/sodium + Example: An extension normally bundled with PHP can be installed + Given I have libsodium on my system + When I install the sodium extension with PIE + Then the extension should have been installed and enabled + + Example: A bundled extension installed with PIE can be uninstalled + Given I have the sodium extension installed with PIE + When I run a command to uninstall an extension + Then the extension should not be installed anymore diff --git a/features/install-extensions.feature b/features/install-extensions.feature index 35076040..cbae8880 100644 --- a/features/install-extensions.feature +++ b/features/install-extensions.feature @@ -1,9 +1,11 @@ Feature: Extensions can be installed with PIE + # pie download Example: The latest version of an extension can be downloaded When I run a command to download the latest version of an extension Then the latest version should have been downloaded + # pie download : Scenario Outline: A version matching the requested constraint can be downloaded When I run a command to download version "" of an extension Then version "" should have been downloaded @@ -13,11 +15,13 @@ Feature: Extensions can be installed with PIE | 2.0.5 | 2.0.5 | | ^2.0 | 2.0.5 | + # pie download :dev-main @non-windows Example: An in-development version can be downloaded on non-Windows systems When I run a command to download version "dev-main" of an extension Then version "dev-main" should have been downloaded + # pie build Example: An extension can be built When I run a command to build an extension Then the extension should have been built @@ -27,10 +31,17 @@ Feature: Extensions can be installed with PIE When I run a command to build an extension Then the extension should have been built + # pie build --with-some-options=foo Example: An extension can be built with configure options When I run a command to build an extension with configure options Then the extension should have been built with options - Example: An extension can be installed - When I run a command to install an extension + # pie install --skip-enable-extension + Example: An extension can be installed without enabling + When I run a command to install an extension without enabling it Then the extension should have been installed + + # pie install + Example: An extension can be installed and enabled + When I run a command to install an extension + Then the extension should have been installed and enabled diff --git a/features/install-in-php-project.feature b/features/install-in-php-project.feature new file mode 100644 index 00000000..3d3abb8c --- /dev/null +++ b/features/install-in-php-project.feature @@ -0,0 +1,7 @@ +Feature: Extensions for a PHP project can be installed with PIE + + # pie install + Example: PIE running in a PHP project suggests missing dependencies + Given I am in a PHP project that has missing extensions + When I run a command to install the extensions + Then I should see all the extensions are now installed diff --git a/features/install-in-pie-project.feature b/features/install-in-pie-project.feature new file mode 100644 index 00000000..2bfcdcf5 --- /dev/null +++ b/features/install-in-pie-project.feature @@ -0,0 +1,7 @@ +Feature: A PIE extension can be installed with PIE + + # pie install + Example: Running PIE in a PIE project will install that PIE extension + Given I am in a PIE project + When I run a command to install the extension + Then the extension should have been installed and enabled diff --git a/features/manage-repositories.feature b/features/manage-repositories.feature index 6fd69b50..0c54d201 100644 --- a/features/manage-repositories.feature +++ b/features/manage-repositories.feature @@ -1,10 +1,12 @@ Feature: Package repositories can be managed with PIE + # pie repository:add ... Example: A package repository can be added Given no repositories have previously been added When I add a package repository Then I should see the package repository can be used by PIE + # pie repository:remove ... Example: A package repository can be removed Given I have previously added a package repository When I remove the package repository diff --git a/features/platform-dependencies.feature b/features/platform-dependencies.feature new file mode 100644 index 00000000..f9dbcf98 --- /dev/null +++ b/features/platform-dependencies.feature @@ -0,0 +1,13 @@ +Feature: Platform dependencies are checked when installing + + # pie info + Example: Extension platform dependencies are listed as dependencies + Given I do not have libsodium on my system + When I display information about the sodium extension with PIE + Then the information should show that libsodium is a missing dependency + + # pie install + Example: Extension platform dependencies will warn the extension is missing a dependency + Given I do not have libsodium on my system + When I install the sodium extension with PIE + Then the extension fails to install due to the missing library diff --git a/features/self-update.feature b/features/self-update.feature new file mode 100644 index 00000000..b23f2b71 --- /dev/null +++ b/features/self-update.feature @@ -0,0 +1,27 @@ +Feature: PIE can update itself and verify it is authentic + + # pie self-update + Example: PIE can update itself + Given I have an old version of PIE + When I update PIE to the latest version + Then I should see I have been updated to the latest version + + # pie self-verify + Example: PIE can verify its authenticity with gh + Given I have a pie.phar built on PHP's GitHub + And I have the gh cli command + When I verify my PIE installation + Then I should see it is verified + + # pie self-verify + Example: PIE can verify its authenticity with openssl + Given I have a pie.phar built on PHP's GitHub + And I do not have the gh cli command + When I verify my PIE installation + Then I should see it is verified + + # pie self-verify + Example: PIE will alert when its authenticity is not verified + Given I have a pie.phar built on a nasty hacker's machine + When I verify my PIE installation + Then I should see it has failed verification diff --git a/features/uninstall-extensions.feature b/features/uninstall-extensions.feature index 1167897e..93002d4f 100644 --- a/features/uninstall-extensions.feature +++ b/features/uninstall-extensions.feature @@ -1,6 +1,7 @@ Feature: Extensions can be uninstalled with PIE + # pie uninstall Example: An extension can be uninstalled - Given an extension was previously installed + Given an extension was previously installed and enabled When I run a command to uninstall an extension Then the extension should not be installed anymore diff --git a/test/assets/example-php-project/.gitignore b/test/assets/example-php-project/.gitignore new file mode 100644 index 00000000..22d0d82f --- /dev/null +++ b/test/assets/example-php-project/.gitignore @@ -0,0 +1 @@ +vendor diff --git a/test/assets/example-php-project/composer.json b/test/assets/example-php-project/composer.json new file mode 100644 index 00000000..d4a25d50 --- /dev/null +++ b/test/assets/example-php-project/composer.json @@ -0,0 +1,9 @@ +{ + "name": "php-pie-test-project/php-pie-test-project", + "description": "Example PHP project for test cases", + "type": "project", + "require": { + "php": "^8.0", + "ext-example_pie_extension": "^2.0" + } +} diff --git a/test/assets/example-php-project/composer.lock b/test/assets/example-php-project/composer.lock new file mode 100644 index 00000000..de41cd27 --- /dev/null +++ b/test/assets/example-php-project/composer.lock @@ -0,0 +1,21 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "2e7e51dfd351f870a1a657e3e49836ad", + "packages": [], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": "^8.0", + "ext-example_pie_extension": "^2.0" + }, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/test/behaviour/CliContext.php b/test/behaviour/CliContext.php index 9d151c5a..05f95df4 100644 --- a/test/behaviour/CliContext.php +++ b/test/behaviour/CliContext.php @@ -13,14 +13,24 @@ use Webmozart\Assert\Assert; use function array_merge; +use function assert; +use function copy; +use function realpath; +use function sprintf; class CliContext implements Context { - private const PHP_BINARY = 'php'; - private string|null $output = null; - private int|null $exitCode = null; + private const PHP_BINARY = 'php'; + private const PIE_BINARY = '/usr/local/bin/pie'; + private const PIE_BINARY_BACKUP = '/usr/local/bin/pie.original'; + private string|null $output = null; + private string|null $errorOutput = null; + private int|null $exitCode = null; /** @var list */ - private array $phpArguments = []; + private array $phpArguments = []; + private string $theExtension = 'example_pie_extension'; + private string $thePackage = 'asgrim/example-pie-extension'; + private string|null $workingDirectory = null; #[When('I run a command to download the latest version of an extension')] public function iRunACommandToDownloadTheLatestVersionOfAnExtension(): void @@ -37,18 +47,42 @@ public function iRunACommandToDownloadSpecificVersionOfAnExtension(string $versi /** @param list $command */ public function runPieCommand(array $command): void { - $pieCommand = array_merge([self::PHP_BINARY, ...$this->phpArguments, 'bin/pie'], $command); + $pieCommand = array_merge([self::PHP_BINARY, ...$this->phpArguments, self::PIE_BINARY], $command); - $proc = (new Process($pieCommand))->mustRun(); + if ($this->workingDirectory !== null) { + $pieCommand[] = '--working-dir'; + $pieCommand[] = $this->workingDirectory; + } + + $proc = new Process($pieCommand, timeout: 120); + $proc->run(); - $this->output = $proc->getOutput(); - $this->exitCode = $proc->getExitCode(); + $this->output = $proc->getOutput(); + $this->errorOutput = $proc->getErrorOutput(); + $this->exitCode = $proc->getExitCode(); } /** @phpstan-assert !null $this->output */ private function assertCommandSuccessful(): void { - Assert::same(0, $this->exitCode); + Assert::same( + 0, + $this->exitCode, + sprintf( + <<<'EOF' + Last command was not successful - exit code was: %d. + + Output: + %s + + Error output: + %s + EOF, + $this->exitCode, + $this->output, + $this->errorOutput, + ), + ); Assert::notNull($this->output); } @@ -113,16 +147,27 @@ public function theExtensionShouldHaveBeenBuiltWithOptions(): void } #[When('I run a command to install an extension')] - #[Given('an extension was previously installed')] + #[Given('an extension was previously installed and enabled')] public function iRunACommandToInstallAnExtension(): void { - $this->runPieCommand(['install', 'asgrim/example-pie-extension']); + $this->theExtension = 'example_pie_extension'; + $this->thePackage = 'asgrim/example-pie-extension'; + $this->runPieCommand(['install', $this->thePackage]); + } + + #[When('I run a command to install an extension without enabling it')] + public function iRunACommandToInstallAnExtensionWithoutEnabling(): void + { + $this->theExtension = 'example_pie_extension'; + $this->thePackage = 'asgrim/example-pie-extension'; + $this->runPieCommand(['install', $this->thePackage, '--skip-enable-extension']); } #[When('I run a command to uninstall an extension')] public function iRunACommandToUninstallAnExtension(): void { - $this->runPieCommand(['uninstall', 'asgrim/example-pie-extension']); + assert($this->thePackage !== ''); + $this->runPieCommand(['uninstall', $this->thePackage]); } #[Then('the extension should not be installed anymore')] @@ -131,16 +176,20 @@ public function theExtensionShouldNotBeInstalled(): void $this->assertCommandSuccessful(); if (Platform::isWindows()) { - Assert::regex($this->output, '#👋 Removed extension: [-\\\_:.a-zA-Z0-9]+\\\php_example_pie_extension.dll#'); + Assert::regex($this->output, '#👋 Removed extension: [-\\\_:.a-zA-Z0-9]+\\\php_' . $this->theExtension . '.dll#'); } else { - Assert::regex($this->output, '#👋 Removed extension: [-_.a-zA-Z0-9/]+/example_pie_extension.so#'); + Assert::regex($this->output, '#👋 Removed extension: [-_.a-zA-Z0-9/]+/' . $this->theExtension . '.so#'); } - $isExtEnabled = (new Process([self::PHP_BINARY, '-r', 'echo extension_loaded("example_pie_extension")?"yes":"no";'])) + $isExtEnabled = (new Process([self::PHP_BINARY, '-r', 'echo extension_loaded("' . $this->theExtension . '")?"yes":"no";'])) ->mustRun() ->getOutput(); - Assert::same($isExtEnabled, 'no'); + Assert::same( + $isExtEnabled, + 'no', + sprintf("Failed to remove extension.\n\nOutput:\n%s\n\nError output:\n%s\n", $this->output, $this->errorOutput), + ); } #[Then('the extension should have been installed')] @@ -148,17 +197,33 @@ public function theExtensionShouldHaveBeenInstalled(): void { $this->assertCommandSuccessful(); + Assert::contains($this->output, 'Extension has NOT been automatically enabled.'); + + if (Platform::isWindows()) { + Assert::regex($this->output, '#Copied DLL to: [-\\\_:.a-zA-Z0-9]+\\\php_' . $this->theExtension . '.dll#'); + + return; + } + + Assert::regex($this->output, '#Install complete: [-_.a-zA-Z0-9/]+/' . $this->theExtension . '.so#'); + } + + #[Then('the extension should have been installed and enabled')] + public function theExtensionShouldHaveBeenInstalledAndEnabled(): void + { + $this->assertCommandSuccessful(); + Assert::contains($this->output, 'Extension is enabled and loaded'); if (Platform::isWindows()) { - Assert::regex($this->output, '#Copied DLL to: [-\\\_:.a-zA-Z0-9]+\\\php_example_pie_extension.dll#'); + Assert::regex($this->output, '#Copied DLL to: [-\\\_:.a-zA-Z0-9]+\\\php_' . $this->theExtension . '.dll#'); return; } - Assert::regex($this->output, '#Install complete: [-_.a-zA-Z0-9/]+/example_pie_extension.so#'); + Assert::regex($this->output, '#Install complete: [-_.a-zA-Z0-9/]+/' . $this->theExtension . '.so#'); - $isExtEnabled = (new Process([self::PHP_BINARY, '-r', 'echo extension_loaded("example_pie_extension")?"yes":"no";'])) + $isExtEnabled = (new Process([self::PHP_BINARY, '-r', 'echo extension_loaded("' . $this->theExtension . '")?"yes":"no";'])) ->mustRun() ->getOutput(); @@ -209,4 +274,150 @@ public function iShouldSeeThePackageRepositoryIsNotUsedByPie(): void Assert::notNull($this->output); Assert::notContains($this->output, 'Path repository (' . __DIR__ . ')'); } + + #[Given('I have libsodium on my system')] + public function iHaveLibsodiumOnMySystem(): void + { + (new Process(['apt-get', 'update'], timeout: 120))->mustRun(); + (new Process(['apt-get', '-y', 'install', 'libsodium-dev'], timeout: 120))->mustRun(); + } + + #[When('I install the sodium extension with PIE')] + #[Given('I have the sodium extension installed with PIE')] + public function iInstallTheSodiumExtensionWithPie(): void + { + $this->theExtension = 'sodium'; + $this->thePackage = 'php/sodium'; + $this->runPieCommand(['install', $this->thePackage]); + } + + #[Given('I do not have libsodium on my system')] + public function iDoNotHaveLibsodiumOnMySystem(): void + { + (new Process(['apt-get', '-y', '-m', 'remove', 'libsodium*'], timeout: 120))->run(); + } + + #[When('I display information about the sodium extension with PIE')] + public function iDisplayInformationAboutTheSodiumExtensionWithPie(): void + { + $this->theExtension = 'sodium'; + $this->thePackage = 'php/sodium'; + $this->runPieCommand(['info', $this->thePackage]); + } + + #[Then('the information should show that libsodium is a missing dependency')] + public function theInformationShouldShowThatLibsodiumIsAMissingDependency(): void + { + Assert::notNull($this->output); + Assert::contains( + $this->output, + 'lib-sodium: * 🚫 (not installed)', + sprintf("Could not find missing lib-sodium.\n\nOutput:\n%s\n\nError output:\n%s\n", $this->output, $this->errorOutput), + ); + } + + #[Then('the extension fails to install due to the missing library')] + public function theExtensionFailsToInstallDueToTheMissingLibrary(): void + { + Assert::notSame(0, $this->exitCode); + Assert::notNull($this->errorOutput); + Assert::regex( + $this->errorOutput, + '#Cannot use php/sodium\'s latest version .* as it requires lib-sodium .* which is missing from your platform.#', + sprintf("Did not detect missing lib-sodium correctly.\n\nOutput:\n%s\n\nError output:\n%s\n", $this->output, $this->errorOutput), + ); + } + + #[Given('I am in a PHP project that has missing extensions')] + public function iAmInAPHPProjectThatHasMissingExtensions(): void + { + $this->runPieCommand(['uninstall', 'asgrim/example-pie-extension']); + + $this->runPieCommand(['show']); + $this->assertCommandSuccessful(); + Assert::notContains($this->output, 'example_pie_extension'); + + $examplePhpProject = (string) realpath(__DIR__ . '/../assets/example-php-project'); + assert($examplePhpProject !== ''); + + $this->workingDirectory = $examplePhpProject; + } + + #[When('I run a command to install the extensions')] + public function iRunACommandToInstallTheExtensions(): void + { + $this->runPieCommand(['install', '--allow-non-interactive-project-install']); + + $this->assertCommandSuccessful(); + } + + #[Then('I should see all the extensions are now installed')] + public function iShouldSeeAllTheExtensionsAreNowInstalled(): void + { + $this->workingDirectory = null; + + $this->runPieCommand(['show']); + $this->assertCommandSuccessful(); + Assert::contains($this->output, 'example_pie_extension'); + } + + #[Given('I am in a PIE project')] + public function iAmInAPIEProject(): void + { + $examplePieProject = (string) realpath('/example-pie-extension'); + assert($examplePieProject !== ''); + + $this->workingDirectory = $examplePieProject; + } + + #[When('I run a command to install the extension')] + public function iRunACommandToInstallTheExtension(): void + { + $this->theExtension = 'example_pie_extension'; + $this->thePackage = 'asgrim/example-pie-extension'; + $this->runPieCommand(['install']); + } + + #[Given('I have an old version of PIE')] + public function iHaveAnOldVersionOfPIE(): void + { + // noop + } + + #[When('I update PIE to the latest version')] + public function iUpdatePIEToTheLatestNightlyVersion(): void + { + $this->runPieCommand(['self-update', '--nightly', '-v']); + + copy(self::PIE_BINARY_BACKUP, self::PIE_BINARY); + } + + #[Then('I should see I have been updated to the latest version')] + public function iShouldSeeIHaveBeenUpdatedToTheLatestVersion(): void + { + $this->assertCommandSuccessful(); + Assert::contains($this->output, '✅ Verified the new PIE version'); + Assert::contains($this->output, '✅ PIE has been upgraded to nightly'); + } + + #[Given('I have a pie.phar built on a nasty hacker\'s machine')] + public function iHaveAPiePharBuiltOnANastyHackerSMachine(): void + { + // noop - the pie.phar built in this does not have attestations + } + + #[When('I verify my PIE installation')] + public function iVerifyMyPIEInstallation(): void + { + $this->runPieCommand(['self-verify']); + } + + #[Then('I should see it has failed verification')] + public function iShouldSeeItHasFailedVerification(): void + { + Assert::same($this->exitCode, 1); + + Assert::notNull($this->errorOutput); + Assert::contains($this->errorOutput, '❌ Failed to verify the pie.phar release'); + } }