diff --git a/.github/actions/setup-magento/action.yml b/.github/actions/setup-magento/action.yml new file mode 100644 index 00000000..409d7e00 --- /dev/null +++ b/.github/actions/setup-magento/action.yml @@ -0,0 +1,136 @@ +name: Set up Magento +description: >- + Provisions a Magento install for CI: sets up PHP + Composer, restores the + Composer download cache, fetches Magento (git clone or the Mage-OS mirror) and + runs `bin/magento setup:install`. The calling job must provide the MySQL/MariaDB + and OpenSearch service containers (reachable at 127.0.0.1:3306 / localhost:9200). + Leaves a ready install in ./magento2 next to the checked-out module. + +inputs: + php-version: + description: PHP version passed to shivammathur/setup-php. + required: true + magento-version: + description: >- + Magento version. With magento-source=git it is the branch/tag to clone. + With magento-source=mage-os it pins `composer create-project` to that + release (falling back to the latest stable only if the mirror lacks that + exact version). Also namespaces the Composer cache. + required: true + magento-source: + description: >- + How to fetch Magento: "git" (github.com/magento/magento2) or "mage-os" + (composer create-project via the Mage-OS mirror). + required: false + default: git + php-extensions: + description: PHP extensions to install. + required: false + default: mbstring, intl, gd, xml, soap, zip, bcmath, pdo_mysql, curl, sockets + composer-auth: + description: >- + COMPOSER_AUTH JSON (Magento Marketplace keys) for authenticated Composer + downloads. Pass the calling workflow's secrets.COMPOSER_AUTH secret. + required: false + default: "" + +runs: + using: composite + steps: + # Fail fast on a bad magento-source: otherwise both download steps' `if` + # conditions are false, no magento2/ is created, and the failure only + # surfaces later as a confusing "working-directory magento2 not found". + - name: Validate inputs + shell: bash + env: + MAGENTO_SOURCE: ${{ inputs.magento-source }} + run: | + case "$MAGENTO_SOURCE" in + git | mage-os) ;; + *) + echo "::error::Invalid magento-source '${MAGENTO_SOURCE}' (expected 'git' or 'mage-os')" + exit 1 + ;; + esac + + - name: Setup PHP + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2 + with: + php-version: ${{ inputs.php-version }} + extensions: ${{ inputs.php-extensions }} + tools: composer:v2 + + # Caches ~/.composer/cache/files — Composer's content-addressed download + # cache, not a resolved dependency set. If the mage-os fallback installs a + # different release than magento-version implies, a mismatched key only + # causes cache misses (re-downloads), never an incorrect install: Composer + # always installs exactly what composer.json resolves to. + - name: Cache Composer packages + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/.composer/cache/files + key: ${{ runner.os }}-composer-${{ inputs.magento-version }}-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer-${{ inputs.magento-version }} + + - name: Download Magento (git clone) + if: ${{ inputs.magento-source == 'git' }} + shell: bash + run: | + # Assign to a shell variable and quote it so an unexpected value can't + # word-split or glob into extra git arguments. + version="${{ inputs.magento-version }}" + git clone --depth=1 --branch="$version" \ + https://github.com/magento/magento2.git magento2 + + - name: Download Magento (Mage-OS mirror) + if: ${{ inputs.magento-source == 'mage-os' }} + shell: bash + env: + COMPOSER_AUTH: ${{ inputs.composer-auth }} + run: | + # Pin create-project to the requested version so runs are reproducible, + # falling back to the latest stable only if the mirror lacks that exact + # release. --no-install: scaffold only here; the "Install Magento" step + # below installs dependencies exactly once (with COMPOSER_AUTH), instead + # of create-project installing them unauthenticated and the install step + # installing them a second time. + mirror=https://mirror.mage-os.org/ + version="${{ inputs.magento-version }}" + if ! composer create-project --no-install --repository-url="$mirror" \ + magento/project-community-edition magento2 "$version"; then + echo "::warning::Mage-OS mirror has no project-community-edition ${version}; using latest stable" + rm -rf magento2 + composer create-project --no-install --repository-url="$mirror" \ + magento/project-community-edition magento2 + fi + + - name: Install Magento + working-directory: magento2 + shell: bash + env: + COMPOSER_AUTH: ${{ inputs.composer-auth }} + run: | + composer config minimum-stability stable + composer config prefer-stable true + composer install --no-interaction --no-progress + bin/magento setup:install \ + --base-url=http://localhost \ + --db-host=127.0.0.1 \ + --db-name=magento \ + --db-user=root \ + --db-password=magento \ + --admin-firstname=Admin \ + --admin-lastname=User \ + --admin-email=admin@example.com \ + --admin-user=admin \ + --admin-password=admin12345 \ + --language=en_US \ + --currency=USD \ + --timezone=Europe/Berlin \ + --use-rewrites=1 \ + --backend-frontname=admin \ + --search-engine=opensearch \ + --opensearch-host=localhost \ + --opensearch-port=9200 \ + --opensearch-index-prefix=magento \ + --cleanup-database diff --git a/.github/workflows/functional-tests.yml b/.github/workflows/functional-tests.yml index e9d17254..eb8e1105 100644 --- a/.github/workflows/functional-tests.yml +++ b/.github/workflows/functional-tests.yml @@ -2,11 +2,16 @@ name: Functional Tests on: pull_request: - branches: [main] push: branches: [main] workflow_dispatch: +concurrency: + # Heavy Magento bootstrap — cancel superseded PR runs to save CI minutes; + # let push/main runs finish so main is always validated directly. + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read @@ -42,63 +47,22 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: path: mageforge - - name: Setup PHP - uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f - with: - php-version: "8.4" - extensions: mbstring, intl, gd, xml, soap, zip, bcmath, pdo_mysql, curl, sockets - tools: composer:v2 - - name: Setup Node.js - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af + uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 with: node-version: "20" - - name: Cache Composer packages - id: composer-cache - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 + - name: Set up Magento + uses: ./mageforge/.github/actions/setup-magento with: - path: ~/.composer/cache/files - key: ${{ runner.os }}-composer-functional-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-composer-functional - - - name: Clone Magento - run: | - git clone --depth=1 --branch=2.4.8 https://github.com/magento/magento2.git magento2 - - - name: Install Magento - working-directory: magento2 - env: - COMPOSER_AUTH: ${{ secrets.COMPOSER_AUTH }} - run: | - composer config minimum-stability stable - composer config prefer-stable true - composer install --no-interaction --no-progress - bin/magento setup:install \ - --base-url=http://localhost \ - --db-host=127.0.0.1 \ - --db-name=magento \ - --db-user=root \ - --db-password=magento \ - --admin-firstname=Admin \ - --admin-lastname=User \ - --admin-email=admin@example.com \ - --admin-user=admin \ - --admin-password=admin12345 \ - --language=en_US \ - --currency=USD \ - --timezone=Europe/Berlin \ - --use-rewrites=1 \ - --backend-frontname=admin \ - --search-engine=opensearch \ - --opensearch-host=localhost \ - --opensearch-port=9200 \ - --opensearch-index-prefix=magento \ - --cleanup-database + php-version: "8.4" + magento-version: 2.4.8 + magento-source: git + composer-auth: ${{ secrets.COMPOSER_AUTH }} - name: Install MageForge Module working-directory: magento2 diff --git a/.github/workflows/label.yml b/.github/workflows/label.yml index 93002363..598baed9 100644 --- a/.github/workflows/label.yml +++ b/.github/workflows/label.yml @@ -6,6 +6,12 @@ name: Labeler on: [pull_request_target] +concurrency: + # One labeling run per PR; a newer event on the same PR supersedes an + # in-flight one so labels can't race each other. + group: labeler-${{ github.event.pull_request.number }} + cancel-in-progress: true + permissions: contents: read @@ -18,7 +24,7 @@ jobs: steps: - name: Label by changed files and branch name - uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5 + uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0 with: repo-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8442c71e..132bff47 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -4,6 +4,13 @@ on: pull_request: push: branches: [main] + workflow_dispatch: + +concurrency: + # Cancel superseded PR runs to save CI minutes; let push/main runs finish so + # main is always validated directly (catches anything pushed straight to main). + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read @@ -15,7 +22,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Set up PHP uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2 @@ -24,7 +31,7 @@ jobs: tools: composer:v2 - name: Cache Composer packages - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.composer/cache/files key: ${{ runner.os }}-composer-lint-${{ hashFiles('composer.json') }} @@ -44,11 +51,18 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - checks: write steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + # save-annotations stores findings as an artifact instead of posting them + # to the GitHub Checks API. That avoids the *second* check-run that used to + # duplicate this job's red status — and the 403 it would otherwise throw + # without `checks: write`. Findings still print in the job log and fail + # this job via its exit code; grant `checks: write` + drop this only if you + # want inline PR annotations (and the extra check-run) back. - name: Trunk Check uses: trunk-io/trunk-action@04ba50e7658c81db7356da96657e6e77f220bfa3 # v1.3.1 + with: + save-annotations: true diff --git a/.github/workflows/magento-compatibility.yml b/.github/workflows/magento-compatibility.yml index a4b8fdf6..a28e54e8 100644 --- a/.github/workflows/magento-compatibility.yml +++ b/.github/workflows/magento-compatibility.yml @@ -2,11 +2,16 @@ name: Magento Compatibility Test on: pull_request: - branches: [main] push: branches: [main] workflow_dispatch: +concurrency: + # Heavy Magento matrix — cancel superseded PR runs to save CI minutes; + # let push/main runs finish so main is always validated directly. + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read @@ -20,13 +25,10 @@ jobs: include: - magento-version: 2.4.7-p10 php-version: "8.3" - search-engine-name: opensearch - magento-version: 2.4.8-p5 php-version: "8.4" - search-engine-name: opensearch - magento-version: 2.4.9 php-version: "8.5" - search-engine-name: opensearch services: mysql: @@ -55,62 +57,17 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: path: mageforge - - name: Setup PHP - uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f + - name: Set up Magento + uses: ./mageforge/.github/actions/setup-magento with: php-version: ${{ matrix.php-version }} - extensions: mbstring, intl, gd, xml, soap, zip, bcmath, pdo_mysql, curl, sockets - tools: composer:v2 - - - name: Cache Composer packages - id: composer-cache - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 - with: - path: ~/.composer/cache/files - key: ${{ runner.os }}-composer-${{ matrix.magento-version }}-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-composer-${{ matrix.magento-version }} - - - name: Clone Magento - run: | - git clone --depth=1 --branch=${{ matrix.magento-version }} https://github.com/magento/magento2.git magento2 - - - name: Check Search Engine status - run: | - curl -s http://localhost:9200/_cluster/health - - - name: Install Magento - working-directory: magento2 - env: - COMPOSER_AUTH: ${{ secrets.COMPOSER_AUTH }} - run: | - composer config minimum-stability stable - composer config prefer-stable true - composer install --no-interaction --no-progress - bin/magento setup:install \ - --base-url=http://localhost \ - --db-host=127.0.0.1 \ - --db-name=magento \ - --db-user=root \ - --db-password=magento \ - --admin-firstname=Admin \ - --admin-lastname=User \ - --admin-email=admin@example.com \ - --admin-user=admin \ - --admin-password=admin12345 \ - --language=en_US \ - --currency=USD \ - --timezone=Europe/Berlin \ - --use-rewrites=1 \ - --backend-frontname=admin \ - --search-engine=opensearch \ - --opensearch-host=localhost \ - --opensearch-port=9200 \ - --opensearch-index-prefix=magento \ - --cleanup-database + magento-version: ${{ matrix.magento-version }} + magento-source: git + composer-auth: ${{ secrets.COMPOSER_AUTH }} - name: Install MageForge Module from current commit working-directory: magento2 @@ -165,4 +122,4 @@ jobs: - name: Test Summary run: | - echo "MageForge module compatibility test with Magento 2.4.8 completed" + echo "MageForge module compatibility test with Magento ${{ matrix.magento-version }} (PHP ${{ matrix.php-version }}) completed" diff --git a/.github/workflows/phpcs.yml b/.github/workflows/phpcs.yml index d4fcbb27..dd9b67d5 100644 --- a/.github/workflows/phpcs.yml +++ b/.github/workflows/phpcs.yml @@ -4,6 +4,13 @@ on: pull_request: push: branches: [main] + workflow_dispatch: + +concurrency: + # Cancel superseded PR runs to save CI minutes; let push/main runs finish so + # main is always validated directly (catches anything pushed straight to main). + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read @@ -13,6 +20,7 @@ env: jobs: check-phpcs: + name: PHPCS runs-on: ubuntu-latest steps: diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index bd0d6c47..9796f72b 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -2,8 +2,17 @@ name: PHPUnit on: pull_request: + # Runs on main too — the badges job needs the coverage + mutation artifacts + # produced by the phpunit/mutation jobs to publish the badge data. push: branches: [main] + workflow_dispatch: + +concurrency: + # Cancel superseded PR runs; let main pushes finish so the badges job + # (publishes coverage/MSI to the badges branch) is never cut off. + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index d77e0595..a50bf1e4 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -5,6 +5,12 @@ on: branches: - main +concurrency: + # Serialize releases: never run two passes at once and never cancel one + # mid-flight — it may be cutting a release or (re)opening the release PR. + group: release-please + cancel-in-progress: false + permissions: contents: write pull-requests: write @@ -14,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Run Release Please - uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4 + uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1 with: # The GitHub token for creating releases and pull requests token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 8c410ede..e94d6a50 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -8,11 +8,16 @@ name: Static Analysis on: pull_request: - branches: [main] push: branches: [main] workflow_dispatch: +concurrency: + # Heavy shared Magento build — cancel superseded PR runs to save CI minutes; + # let push/main runs finish so main is always validated directly. + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read @@ -47,57 +52,13 @@ jobs: with: path: mageforge - - name: Setup PHP - uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2 + - name: Set up Magento + uses: ./mageforge/.github/actions/setup-magento with: php-version: "8.4" - extensions: mbstring, intl, gd, xml, soap, zip, bcmath, pdo_mysql, curl, sockets - tools: composer:v2 - - - name: Cache Composer packages - id: composer-cache - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - path: ~/.composer/cache/files - key: ${{ runner.os }}-composer-2.4.8-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-composer-2.4.8 - - - name: Download Magento - run: | - composer create-project \ - --repository-url=https://mirror.mage-os.org/ \ - magento/project-community-edition \ - magento2 - - - name: Install Magento - working-directory: magento2 - env: - COMPOSER_AUTH: ${{ secrets.COMPOSER_AUTH }} - run: | - composer config minimum-stability stable - composer config prefer-stable true - composer install --no-interaction --no-progress - bin/magento setup:install \ - --base-url=http://localhost \ - --db-host=127.0.0.1 \ - --db-name=magento \ - --db-user=root \ - --db-password=magento \ - --admin-firstname=Admin \ - --admin-lastname=User \ - --admin-email=admin@example.com \ - --admin-user=admin \ - --admin-password=admin12345 \ - --language=en_US \ - --currency=USD \ - --timezone=Europe/Berlin \ - --use-rewrites=1 \ - --backend-frontname=admin \ - --search-engine=opensearch \ - --opensearch-host=localhost \ - --opensearch-port=9200 \ - --opensearch-index-prefix=magento \ - --cleanup-database + magento-version: 2.4.8 + magento-source: mage-os + composer-auth: ${{ secrets.COMPOSER_AUTH }} - name: Install MageForge module and PHPStan tooling working-directory: magento2 @@ -110,8 +71,12 @@ jobs: # Allow the PHPStan extension installer plugin composer config --no-plugins allow-plugins.phpstan/extension-installer true - # PHPStan + Magento extension (consumed by the phpstan job) - composer require --dev --no-update bitexpert/phpstan-magento "phpstan/phpstan:^2.0" phpstan/extension-installer + # PHPStan + Magento extension (consumed by the phpstan job). Pinned to + # exact versions: phpstan.neon opts into bleedingEdge, whose rules + # change between releases, so an unpinned `^2.0` lets a new PHPStan + # release fail unrelated PRs. Bump these deliberately (keep in sync + # with the locally installed PHPStan) instead of floating. + composer require --dev --no-update "bitexpert/phpstan-magento:0.43.0" "phpstan/phpstan:2.2.2" phpstan/extension-installer composer update --with-dependencies bin/magento setup:upgrade diff --git a/src/Service/ThemeBuilder/HyvaThemes/Builder.php b/src/Service/ThemeBuilder/HyvaThemes/Builder.php index 0b65b3a9..53bd43dd 100644 --- a/src/Service/ThemeBuilder/HyvaThemes/Builder.php +++ b/src/Service/ThemeBuilder/HyvaThemes/Builder.php @@ -276,7 +276,7 @@ public function watch( } } - $exitCode = $process->run(static function ($type, $buffer) use ($output): void { + $exitCode = $process->run(static function (string $type, string $buffer) use ($output): void { $output->write($buffer); }); diff --git a/src/Service/ThemeBuilder/TailwindCSS/Builder.php b/src/Service/ThemeBuilder/TailwindCSS/Builder.php index 75f0ec94..34cb8638 100644 --- a/src/Service/ThemeBuilder/TailwindCSS/Builder.php +++ b/src/Service/ThemeBuilder/TailwindCSS/Builder.php @@ -244,7 +244,7 @@ public function watch( } } - $exitCode = $process->run(static function ($type, $buffer) use ($output): void { + $exitCode = $process->run(static function (string $type, string $buffer) use ($output): void { $output->write($buffer); }); diff --git a/tests/Unit/Model/TemplateEngine/Decorator/InspectorHintsTest.php b/tests/Unit/Model/TemplateEngine/Decorator/InspectorHintsTest.php index df4f898c..0938bee2 100644 --- a/tests/Unit/Model/TemplateEngine/Decorator/InspectorHintsTest.php +++ b/tests/Unit/Model/TemplateEngine/Decorator/InspectorHintsTest.php @@ -90,6 +90,28 @@ private function createBlock(): BlockInterface&MockObject return $this->createMock(BlockInterface::class); } + /** + * Extracts and JSON-decodes the metadata embedded in the + * data-mageforge-block attribute of rendered inspector output. + * + * @return array + */ + private function decodeBlockMetadata(string $result): array + { + // Guarding on the preg_match return value narrows $matches to the + // matched shape for every PHPStan version: capture group 1 is then + // known to exist, so no null-coalesce fallback is needed (which newer + // PHPStan/bleedingEdge flags as redundant via nullCoalesce.offset). + if (preg_match('/data-mageforge-block="([^"]*)"/', $result, $matches) !== 1) { + $this->fail('render output should contain a data-mageforge-block attribute'); + } + + $metadata = json_decode(html_entity_decode($matches[1], ENT_QUOTES), true); + $this->assertIsArray($metadata); + + return $metadata; + } + public function testRenderReturnsSubjectResultWhenShowBlockHintsDisabled(): void { $block = $this->createBlock(); @@ -176,11 +198,7 @@ public function testRenderEmbedsMetadataAsEscapedJson(): void $inspector = $this->createInspectorHints(); $result = $inspector->render($block, '/var/www/html/some/template.phtml'); - preg_match('/data-mageforge-block="([^"]*)"/', $result, $matches); - $this->assertNotEmpty($matches); - $decodedJson = html_entity_decode($matches[1] ?? '', ENT_QUOTES); - $metadata = json_decode($decodedJson, true); - $this->assertIsArray($metadata); + $metadata = $this->decodeBlockMetadata($result); $this->assertSame('mageforge-xyz', $metadata['id']); $this->assertSame('some/template.phtml', $metadata['template']); @@ -199,9 +217,7 @@ public function testRenderExtractsModuleNameFromRealBlockClassName(): void $inspector = $this->createInspectorHints(); $result = $inspector->render($block, '/var/www/html/template.phtml'); - preg_match('/data-mageforge-block="([^"]*)"/', $result, $matches); - $metadata = json_decode(html_entity_decode($matches[1] ?? '', ENT_QUOTES), true); - $this->assertIsArray($metadata); + $metadata = $this->decodeBlockMetadata($result); $this->assertSame('OpenForgeProject_MageForge', $metadata['module']); } @@ -227,9 +243,7 @@ public function testRenderDetectsParentBlockAndAlias(): void $inspector = $this->createInspectorHints(); $result = $inspector->render($block, '/var/www/html/template.phtml'); - preg_match('/data-mageforge-block="([^"]*)"/', $result, $matches); - $metadata = json_decode(html_entity_decode($matches[1] ?? '', ENT_QUOTES), true); - $this->assertIsArray($metadata); + $metadata = $this->decodeBlockMetadata($result); $this->assertSame('parent.block', $metadata['parent']); $this->assertSame('child.block', $metadata['alias']);