diff --git a/.github/actions/acceptance-lane/action.yml b/.github/actions/acceptance-lane/action.yml new file mode 100644 index 00000000..f79094e9 --- /dev/null +++ b/.github/actions/acceptance-lane/action.yml @@ -0,0 +1,49 @@ +name: Acceptance lane +description: >- + Boots a Shopware lane through bin/ci-write-compose.sh with the plugin installed and the + administration built, then installs the acceptance suite. Expects the plugin checked out at + agentic-commerce/. + +inputs: + lane: + description: 'Lane for bin/ci-write-compose.sh: 6.5.x, 6.6.x, 6.7.14.x or trunk' + required: true + shopware-ref: + description: 'shopware/shopware ref to check out' + required: true + +runs: + using: composite + steps: + - uses: actions/checkout@v6 + with: + repository: shopware/shopware + ref: ${{ inputs.shopware-ref }} + path: shopware + - uses: actions/setup-node@v6 + with: + node-version: '24' + cache: npm + cache-dependency-path: agentic-commerce/tests/acceptance/package-lock.json + - name: Write the lane's compose stack + shell: bash + env: + LANE: ${{ inputs.lane }} + run: agentic-commerce/bin/ci-write-compose.sh "${GITHUB_WORKSPACE}/shopware" "${LANE}" + # The Setup project logs into the administration, so the bootstrap builds it. "auto" keeps each + # lane's default build tool and leaves the 6.6 feature dump untouched after the script exits. + - name: Boot the shop with the administration built + shell: bash + env: + SHOPWARE_REF: ${{ inputs.lane }} + CI_ADMIN_BOOTSTRAP_MODE: cold + CI_ADMIN_BROWSER_VALIDATE: '0' + CI_ADMIN_KEEP_STACK: '1' + run: agentic-commerce/bin/ci-admin-smoke.sh "${GITHUB_WORKSPACE}/shopware" auto + # After the bootstrap: bin/ci-smoke.sh copies the plugin checkout into the shop. + - name: Install the acceptance suite + shell: bash + working-directory: agentic-commerce/tests/acceptance + run: | + npm ci --no-audit --no-fund + npx playwright install chromium --with-deps \ No newline at end of file diff --git a/.github/actions/acceptance-report/action.yml b/.github/actions/acceptance-report/action.yml new file mode 100644 index 00000000..0d4d8b74 --- /dev/null +++ b/.github/actions/acceptance-report/action.yml @@ -0,0 +1,35 @@ +name: Acceptance report +description: >- + Merges every acceptance-blob-* artifact of the run into one Playwright report, writes the failure + table to the step summary and uploads the report as the acceptance-report artifact. Expects the + plugin checked out at the workspace root. + +runs: + using: composite + steps: + - uses: actions/setup-node@v6 + with: + node-version: '24' + cache: npm + cache-dependency-path: tests/acceptance/package-lock.json + - name: Install the acceptance suite + shell: bash + working-directory: tests/acceptance + run: npm ci --no-audit --no-fund + - name: Download the shard reports + uses: actions/download-artifact@v7 + with: + pattern: acceptance-blob-* + path: ${{ runner.temp }}/acceptance-blobs + merge-multiple: true + - name: Merge the shard reports + shell: bash + run: bin/ci-acceptance-report.sh "${RUNNER_TEMP}/acceptance-blobs" "${RUNNER_TEMP}/acceptance-report" + - name: Upload the merged report + uses: actions/upload-artifact@v6 + with: + name: acceptance-report + path: ${{ runner.temp }}/acceptance-report + if-no-files-found: error + overwrite: true + retention-days: 14 \ No newline at end of file diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml new file mode 100644 index 00000000..f22282b2 --- /dev/null +++ b/.github/workflows/acceptance.yml @@ -0,0 +1,209 @@ +name: Acceptance + +# The tests/acceptance suite: static checks, the lane x shard matrix and, on pull requests, the +# changed specs repeated ten times. The callers merge the shard reports once per run +# (.github/actions/acceptance-report). + +on: + workflow_call: + inputs: + lanes: + description: 'JSON array of bin/ci-write-compose.sh lanes' + type: string + shards: + type: number + repeat_each: + type: number + fail_on_flaky: + description: 'Fail a lane when a test only passes on a retry' + type: boolean + run_changed: + description: 'On pull requests, repeat the changed specs ten times without retries' + type: boolean + skip_static: + description: 'Skip lint, typecheck and the Signer project (another call runs them)' + type: boolean + shopware_65_ref: + type: string + shopware_66_ref: + type: string + shopware_trunk_ref: + type: string + workflow_dispatch: + +# A caller's env does not reach a called workflow, so every layer resolves the refs itself. +env: + SHOPWARE_65_REF: ${{ inputs.shopware_65_ref || vars.SHOPWARE_65_REF || '6.5.x' }} + SHOPWARE_66_REF: ${{ inputs.shopware_66_ref || vars.SHOPWARE_66_REF || '6.6.x' }} + SHOPWARE_TRUNK_REF: ${{ inputs.shopware_trunk_ref || vars.SHOPWARE_TRUNK_REF || 'trunk' }} + SHOPWARE_6714_REF: ${{ vars.SHOPWARE_6714_REF || '6.7.14.x' }} + +permissions: + contents: read + +jobs: + acceptance-static: + if: ${{ !inputs.skip_static }} + runs-on: ubuntu-latest + defaults: + run: + working-directory: tests/acceptance + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: '24' + cache: npm + cache-dependency-path: tests/acceptance/package-lock.json + - run: npm ci --no-audit --no-fund + - run: npm run lint + - run: npm run typecheck + # The RFC 9421 signer checks need no shop. + - run: npm run test:signer + + acceptance-plan: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.plan.outputs.matrix }} + changed: ${{ steps.plan.outputs.changed }} + steps: + - uses: actions/checkout@v6 + with: + # On pull_request the merge ref's first parent is the base branch. + fetch-depth: 2 + - id: plan + env: + LANES: ${{ inputs.lanes || '["trunk"]' }} + SHARDS: ${{ inputs.shards || 1 }} + RUN_CHANGED: ${{ inputs.run_changed && github.event_name == 'pull_request' }} + run: | + set -euo pipefail + # jq -e fails on an empty lane list instead of planning an empty run. + matrix="$(jq -ce --argjson shards "${SHARDS}" \ + 'select(length > 0) | {include: [.[] as $lane | range(1; $shards + 1) | {lane: $lane, shard: ., shards: $shards}]}' <<<"${LANES}")" + changed='[]' + if [[ "${RUN_CHANGED}" == "true" ]]; then + # Setup and Signer are dependency projects that already run in every acceptance job. + # KnownBlocked specs are not gating, so they are not repeated either. + # quotePath=false: git would otherwise print non-ASCII paths quoted, matching no spec. + changed="$(git -c core.quotePath=false diff --name-only --diff-filter=d HEAD^1 HEAD -- \ + ':(glob)tests/acceptance/tests/**/*.spec.ts' \ + ':(exclude,glob)tests/acceptance/tests/Setup/**' \ + ':(exclude,glob)tests/acceptance/tests/Signer/**' \ + ':(exclude,glob)tests/acceptance/tests/KnownBlocked/**' \ + | jq -Rnc '[inputs | select(length > 0) | ltrimstr("tests/acceptance/")]')" + fi + echo "matrix=${matrix}" >> "${GITHUB_OUTPUT}" + echo "changed=${changed}" >> "${GITHUB_OUTPUT}" + echo "Changed specs repeated ten times: ${changed}" >> "${GITHUB_STEP_SUMMARY}" + + acceptance: + name: acceptance (${{ matrix.lane == '6.5.x' && '6.5.8' || matrix.lane }}, ${{ matrix.shard }}/${{ matrix.shards }}) + needs: acceptance-plan + runs-on: ubuntu-latest + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.acceptance-plan.outputs.matrix) }} + steps: + - uses: actions/checkout@v6 + with: + path: agentic-commerce + - uses: ./agentic-commerce/.github/actions/acceptance-lane + with: + lane: ${{ matrix.lane }} + shopware-ref: ${{ matrix.lane == '6.5.x' && env.SHOPWARE_65_REF || matrix.lane == '6.6.x' && env.SHOPWARE_66_REF || matrix.lane == '6.7.14.x' && env.SHOPWARE_6714_REF || env.SHOPWARE_TRUNK_REF }} + - id: suite + working-directory: agentic-commerce/tests/acceptance + env: + # bin/ci-write-compose.sh publishes the shop on this URL inside the container and on the + # runner. The agent-profile fixture writes into SHOPWARE_DIR/public and the shop fetches + # the profile back from this URL in development mode. + APP_URL: http://localhost:8000 + SHOPWARE_DIR: ${{ github.workspace }}/shopware + # The lane's container runs with SHELL_VERBOSITY=-1 (bin/ci-write-compose.sh), which mutes + # the JSON the signing-key commands print. + UCP_CONSOLE: docker compose exec -T -e SHELL_VERBOSITY=0 web php bin/console + ACCEPTANCE_LANE: ${{ matrix.lane }} + PLAYWRIGHT_BLOB_OUTPUT_FILE: ${{ github.workspace }}/acceptance-blob/acceptance-${{ matrix.lane }}-${{ matrix.shard }}.zip + SHARD: ${{ matrix.shard }}/${{ matrix.shards }} + REPEAT_EACH: ${{ inputs.repeat_each || 1 }} + FAIL_ON_FLAKY: ${{ inputs.fail_on_flaky && '--fail-on-flaky-tests' || '' }} + # FAIL_ON_FLAKY stays unquoted, so an empty value passes no argument. + run: npx playwright test --grep-invert @UcpKnownBlocked --shard="${SHARD}" --repeat-each="${REPEAT_EACH}" ${FAIL_ON_FLAKY} --reporter=blob,list + - if: ${{ !cancelled() && steps.suite.outcome != 'skipped' }} + uses: actions/upload-artifact@v6 + with: + name: acceptance-blob-${{ matrix.lane }}-${{ matrix.shard }} + path: acceptance-blob + if-no-files-found: error + overwrite: true + retention-days: 7 + + # A spec that passes once can still be flaky: every spec this pull request adds or changes runs + # ten times without retries on trunk. + acceptance-tests-changed: + needs: acceptance-plan + if: needs.acceptance-plan.outputs.changed != '[]' + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v6 + with: + path: agentic-commerce + - uses: ./agentic-commerce/.github/actions/acceptance-lane + with: + lane: trunk + shopware-ref: ${{ env.SHOPWARE_TRUNK_REF }} + - working-directory: agentic-commerce/tests/acceptance + env: + APP_URL: http://localhost:8000 + SHOPWARE_DIR: ${{ github.workspace }}/shopware + UCP_CONSOLE: docker compose exec -T -e SHELL_VERBOSITY=0 web php bin/console + # Paths chosen by the pull request: read as data, never interpolated into the script. + CHANGED_SPECS: ${{ needs.acceptance-plan.outputs.changed }} + run: | + mapfile -t specs < <(jq -r '.[]' <<<"${CHANGED_SPECS}") + # The plan leaves out KnownBlocked specs, so a changed spec that selects no test (no project + # tag, or a name Playwright reads as a regex) must fail the job. Playwright only fails when the + # whole run selects nothing, so each spec is listed on its own first, without Setup and Signer. + for spec in "${specs[@]}"; do + npx playwright test --list --no-deps --grep-invert @UcpKnownBlocked --reporter=list "${spec}" || exit 1 + done + # One run for all specs, so Setup and Signer execute once. + # Two workers, the default on the 4-vCPU runner, so a smaller runner keeps the parallel load. + # No `--` before the specs: Playwright drops every argument after it from the test filter and + # runs the whole suite. The plan only emits paths starting with tests/, never an option. + # -x stops at the first failure, as core's job does: one failure already shows the flake. + npx playwright test --grep-invert @UcpKnownBlocked --repeat-each=10 --retries=0 --workers=2 -x --reporter=list "${specs[@]}" + # The failures' traces hold every Admin API response the ATS asserts without its body; the shop + # log holds the server-side errors. Both carry tokens of this throwaway shop, like the lane blobs. + - if: failure() + uses: actions/upload-artifact@v6 + with: + name: acceptance-tests-changed-failure + path: | + agentic-commerce/tests/acceptance/test-results + shopware/var/log + if-no-files-found: ignore + overwrite: true + retention-days: 7 + + acceptance-check: + # always(), not !cancelled(): a skipped gate would count as passing. + if: always() + needs: + - acceptance-static + - acceptance-plan + - acceptance + - acceptance-tests-changed + runs-on: ubuntu-latest + steps: + - uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # v1.3.0 + with: + jobs: ${{ toJSON(needs) }} + # Only the two conditional jobs may be skipped, and only when their condition says so. + # alls-green drops the empty entries of this comma-separated list. + allowed-skips: >- + ${{ inputs.skip_static && 'acceptance-static' || '' }}, + ${{ needs.acceptance-plan.outputs.changed == '[]' && 'acceptance-tests-changed' || '' }} \ No newline at end of file diff --git a/.github/workflows/admin.yml b/.github/workflows/admin.yml new file mode 100644 index 00000000..69ed60e5 --- /dev/null +++ b/.github/workflows/admin.yml @@ -0,0 +1,117 @@ +name: Administration + +# Administration lint, Jest with coverage, and the per-lane webpack and Vite build gates. + +on: + workflow_call: + inputs: + shopware_65_ref: + type: string + shopware_66_ref: + type: string + shopware_trunk_ref: + type: string + workflow_dispatch: + +# A caller's env does not reach a called workflow, so every layer resolves the refs itself. +env: + SHOPWARE_65_REF: ${{ inputs.shopware_65_ref || vars.SHOPWARE_65_REF || '6.5.x' }} + SHOPWARE_66_REF: ${{ inputs.shopware_66_ref || vars.SHOPWARE_66_REF || '6.6.x' }} + SHOPWARE_TRUNK_REF: ${{ inputs.shopware_trunk_ref || vars.SHOPWARE_TRUNK_REF || 'trunk' }} + +permissions: + contents: read + +jobs: + admin-static: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + path: agentic-commerce + - uses: actions/checkout@v6 + with: + repository: shopware/shopware + ref: ${{ env.SHOPWARE_TRUNK_REF }} + path: shopware + - uses: actions/setup-node@v6 + with: + node-version: '24' + cache: npm + cache-dependency-path: agentic-commerce/package-lock.json + - run: npm ci --no-audit --no-fund + working-directory: agentic-commerce + - run: npm run ci:admin-static + working-directory: agentic-commerce + env: + ADMIN_PATH: ${{ github.workspace }}/shopware/src/Administration/Resources/app/administration + - name: Upload Jest coverage + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v6 + with: + name: admin-jest-coverage + path: agentic-commerce/tests/jest/administration/coverage + if-no-files-found: warn + retention-days: 7 + + admin-matrix: + runs-on: ubuntu-latest + name: admin-matrix (${{ matrix.lane == '6.5.x' && '6.5.8' || matrix.lane }}, ${{ matrix.mode }}) + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - lane: '6.5.x' + mode: webpack + - lane: '6.6.x' + mode: webpack + - lane: '6.6.x' + mode: vite + - lane: 'trunk' + mode: vite + steps: + - uses: actions/checkout@v6 + with: + path: agentic-commerce + - uses: actions/checkout@v6 + with: + repository: shopware/shopware + ref: ${{ matrix.lane == '6.5.x' && env.SHOPWARE_65_REF || matrix.lane == '6.6.x' && env.SHOPWARE_66_REF || env.SHOPWARE_TRUNK_REF }} + path: shopware + - run: npm ci --no-audit --no-fund + working-directory: agentic-commerce + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('agentic-commerce/package-lock.json') }} + - name: Install Playwright browser + system deps (cache miss) + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: npx playwright install chromium --with-deps + working-directory: agentic-commerce + - name: Install Playwright system deps (cached browser) + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: npx playwright install-deps chromium + working-directory: agentic-commerce + - run: chmod +x agentic-commerce/bin/ci-smoke.sh agentic-commerce/bin/ci-admin-smoke.sh agentic-commerce/bin/ci-write-compose.sh + - run: agentic-commerce/bin/ci-write-compose.sh "$GITHUB_WORKSPACE/shopware" "${{ matrix.lane }}" + - run: agentic-commerce/bin/ci-admin-smoke.sh "$GITHUB_WORKSPACE/shopware" "${{ matrix.mode }}" + env: + SHOPWARE_REF: ${{ matrix.lane }} + CI_ADMIN_BOOTSTRAP_MODE: cold + CI_ADMIN_BROWSER_VALIDATE: 1 + CI_ADMIN_KEEP_STACK: 0 + + admin-check: + # always(), not !cancelled(): a skipped gate would count as passing. + if: always() + needs: + - admin-static + - admin-matrix + runs-on: ubuntu-latest + steps: + - uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # v1.3.0 + with: + jobs: ${{ toJSON(needs) }} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 255c912a..4cdd06b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,10 @@ name: CI +# Orchestrates the layer workflows (php.yml, admin.yml, storefront.yml, acceptance.yml, +# package-zip.yml) and aggregates them in validation-gate, the one check branch protection and +# store-release.yml rely on. Every event runs the full validation: there is no fast path that +# trusts an earlier run, so a green gate always means the work ran on this commit. + on: push: branches: @@ -7,6 +12,9 @@ on: tags: - 'v[0-9]+.[0-9]+.[0-9]+' pull_request: + # labeled: adding run/acceptance-full has to start a run. Any other label change reruns CI + # too. Skipping the jobs on those runs would post a passing gate over an earlier red one. + types: [opened, synchronize, reopened, labeled] workflow_dispatch: inputs: sdk_ref: @@ -25,31 +33,15 @@ on: description: 'Shopware trunk/6.7 ref' default: 'trunk' required: true - run_full_matrix: - description: 'Run the full validation matrix even when no pull request requires it' - default: false - required: false - type: boolean - -env: - # Compatibility lanes intentionally exercise Shopware versions with known - # advisories. Keep dependency resolution running, then report advisories - # without making them the test result. Composer 2.9 uses the first variable; - # newer Composer versions use the policy variable. - COMPOSER_NO_SECURITY_BLOCKING: '1' - COMPOSER_POLICY_ADVISORIES_BLOCK: '0' - UCP_SDK_REF: ${{ github.event.inputs.sdk_ref || vars.UCP_SDK_REF || 'main' }} - SHOPWARE_65_REF: ${{ github.event.inputs.shopware_65_ref || vars.SHOPWARE_65_REF || '6.5.x' }} - SHOPWARE_66_REF: ${{ github.event.inputs.shopware_66_ref || vars.SHOPWARE_66_REF || '6.6.x' }} - SHOPWARE_TRUNK_REF: ${{ github.event.inputs.shopware_trunk_ref || vars.SHOPWARE_TRUNK_REF || 'trunk' }} - SHOPWARE_6714_REF: ${{ vars.SHOPWARE_6714_REF || '6.7.14.x' }} - PHPSTAN_SHOPWARE_VERSION: '0.2.4' permissions: - actions: read - checks: read contents: read - pull-requests: read + +concurrency: + # Only a pull request's superseded runs cancel each other. Every other run gets its own group, so + # a main push, a tag push or a dispatch on the same commit never cancels the run a release reads. + group: ci-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: shell-lint: @@ -77,617 +69,100 @@ jobs: - name: Changelog parity run: bin/ci-assert-changelog-parity.sh - verify-main-validation: - runs-on: ubuntu-latest - outputs: - run_full_matrix: ${{ steps.decide.outputs.run_full_matrix }} - reason: ${{ steps.decide.outputs.reason }} - pr_number: ${{ steps.decide.outputs.pr_number }} - pr_url: ${{ steps.decide.outputs.pr_url }} - pr_head_sha: ${{ steps.decide.outputs.pr_head_sha }} - pr_merge_commit_sha: ${{ steps.decide.outputs.pr_merge_commit_sha }} - checked_at: ${{ steps.decide.outputs.checked_at }} - steps: - - id: decide - env: - EVENT_NAME: ${{ github.event_name }} - REF_NAME: ${{ github.ref_name }} - RUN_FULL_MATRIX_INPUT: ${{ github.event.inputs.run_full_matrix || 'false' }} - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - checked_at="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" - - emit() { - echo "run_full_matrix=$1" >> "$GITHUB_OUTPUT" - echo "reason=$2" >> "$GITHUB_OUTPUT" - echo "pr_number=${3:-}" >> "$GITHUB_OUTPUT" - echo "pr_url=${4:-}" >> "$GITHUB_OUTPUT" - echo "pr_head_sha=${5:-}" >> "$GITHUB_OUTPUT" - echo "pr_merge_commit_sha=${6:-}" >> "$GITHUB_OUTPUT" - echo "checked_at=${checked_at}" >> "$GITHUB_OUTPUT" - echo "Validation decision: run_full_matrix=$1 reason=$2" - } - - if [[ "${EVENT_NAME}" == "pull_request" ]]; then - emit true pull_request - exit 0 - fi - - if [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then - if [[ "${RUN_FULL_MATRIX_INPUT}" == "true" ]]; then - emit true workflow_dispatch_requested - else - emit false workflow_dispatch_fast_path - fi - exit 0 - fi - - if [[ "${EVENT_NAME}" != "push" || "${REF_NAME}" != "main" ]]; then - emit true unsupported_event - exit 0 - fi - - pulls_json="$(gh api -H "Accept: application/vnd.github+json" "/repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/pulls" 2>/tmp/commit-pulls.err || true)" - - if [[ -z "${pulls_json}" ]] || ! jq -e type >/dev/null 2>&1 <<<"${pulls_json}"; then - emit true commit_pr_lookup_failed - exit 0 - fi - - pr_json="$(jq -c '[.[] | select(.merged_at != null and .base.ref == "main")] | sort_by(.merged_at) | last // empty' <<<"${pulls_json}")" - - if [[ -z "${pr_json}" ]]; then - emit true no_merged_pr_for_main_commit - exit 0 - fi - - pr_number="$(jq -r '.number' <<<"${pr_json}")" - pr_url="$(jq -r '.html_url' <<<"${pr_json}")" - pr_head_sha="$(jq -r '.head.sha' <<<"${pr_json}")" - pr_merge_commit_sha="$(jq -r '.merge_commit_sha // ""' <<<"${pr_json}")" - - checks_file="$(mktemp)" - if ! gh pr checks "${pr_number}" --repo "${GITHUB_REPOSITORY}" --json name,state,bucket,workflow >"${checks_file}" 2>/tmp/pr-checks.err; then - emit true pr_checks_lookup_failed "${pr_number}" "${pr_url}" "${pr_head_sha}" "${pr_merge_commit_sha}" - exit 0 - fi - - expected_checks=( - "shell-lint" - "docs-lint" - "admin-static" - "php-quality (php-8.1)" - "php-quality (php-8.2)" - "php-quality (6.7.11)" - "php-quality (6.7.12)" - "php-quality (trunk)" - "future-compatibility" - "shopware-matrix (6.5.8)" - "shopware-matrix (6.6.x)" - "shopware-matrix (trunk)" - "mysql84-install" - "admin-matrix (6.5.8, webpack)" - "admin-matrix (6.6.x, webpack)" - "admin-matrix (6.6.x, vite)" - "admin-matrix (trunk, vite)" - "storefront-matrix (6.5.8)" - "storefront-matrix (6.6.x)" - "storefront-matrix (trunk)" - "zip-artifact / Package extension zip" - "zip-artifact / Install the zip (6.5.x)" - "zip-artifact / Install the zip (6.6.x)" - "zip-artifact / Install the zip (trunk)" - ) - - for check_name in "${expected_checks[@]}"; do - states="$(jq -r --arg name "${check_name}" '[.[] | select(.name == $name) | .state] | unique | join(",")' "${checks_file}")" - - if [[ -z "${states}" ]]; then - emit true "missing_check:${check_name}" "${pr_number}" "${pr_url}" "${pr_head_sha}" "${pr_merge_commit_sha}" - exit 0 - fi - - if [[ "${states}" != "SUCCESS" ]]; then - emit true "non_success_check:${check_name}:${states}" "${pr_number}" "${pr_url}" "${pr_head_sha}" "${pr_merge_commit_sha}" - exit 0 - fi - done - - emit false trusted_pr_checks_success "${pr_number}" "${pr_url}" "${pr_head_sha}" "${pr_merge_commit_sha}" - - php-quality: + # A job only blocks a merge if a gate needs it. This proves, for every gated workflow, that no + # job was added without its gate noticing. + workflow-lint: runs-on: ubuntu-latest - name: php-quality (${{ matrix.lane }}) - needs: - - verify-main-validation - if: needs.verify-main-validation.outputs.run_full_matrix == 'true' - strategy: - fail-fast: false - matrix: - include: - - lane: 'php-8.1' - php-version: '8.1' - - lane: 'php-8.2' - php-version: '8.2' - # Future-compatibility rules inspect the upcoming core API annotations, which are - # available only on trunk. - - lane: 'trunk' - php-version: '8.2' - shopware-ref: 'trunk' - # 6.7.11.x: core still ships Defaults::SALES_CHANNEL_TYPE_AGENTIC_COMMERCE here - # (present only in 6.7.10-6.7.11), so this lane keeps exercising the "core owns the - # feature" coexistence path. Moving branch ref -> always the latest 6.7.11 patch. - - lane: '6.7.11' - php-version: '8.2' - shopware-ref: '6.7.11.x' - # 6.7.12.x: core removed that constant and handed the feature back to the plugin, so - # this lane proves the "plugin re-owns the feature" path on the 6.7 framework. Moving - # branch ref -> always the latest 6.7.12 patch. - - lane: '6.7.12' - php-version: '8.2' - shopware-ref: '6.7.12.x' steps: - uses: actions/checkout@v6 - with: - path: agentic-commerce - - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-version }} - coverage: none - - uses: actions/checkout@v6 - with: - repository: shopware/shopware - ref: ${{ matrix.shopware-ref || matrix.php-version == '8.1' && env.SHOPWARE_65_REF || env.SHOPWARE_66_REF }} - path: shopware - - run: composer install --no-dev --no-scripts --no-interaction --prefer-dist - working-directory: shopware - - run: composer install --no-interaction --prefer-dist - working-directory: agentic-commerce - - name: Install opt-in future compatibility rules - if: matrix.php-version != '8.1' - # Install without plugins so phpstan-shopware's default rules.neon is not registered. - # The package requires PHP 8.2, while this plugin remains compatible with PHP 8.1. - run: composer require --dev shopwarelabs/phpstan-shopware:0.2.4 --no-interaction --no-plugins --ignore-platform-req=php - working-directory: agentic-commerce - - name: Capture plugin Composer advisory report - if: matrix.lane == 'php-8.2' - working-directory: agentic-commerce - run: | - audit_file="${GITHUB_WORKSPACE}/composer-audit-plugin.json" - audit_status=0 - - if composer audit --no-dev --format=json > "${audit_file}"; then - audit_status=0 - else - audit_status=$? - fi - - if [[ ! -s "${audit_file}" ]]; then - if [[ "${audit_status}" -eq 0 ]]; then - printf '{"advisories":{},"abandoned":[]}\n' > "${audit_file}" - else - printf '{"error":"composer audit produced no JSON","exitCode":%d}\n' "${audit_status}" > "${audit_file}" - fi - fi - - echo "Composer audit report written to ${audit_file} (exit ${audit_status})." - - name: Upload plugin Composer advisory report - if: always() && matrix.lane == 'php-8.2' - uses: actions/upload-artifact@v6 - with: - name: composer-audit-plugin - path: composer-audit-plugin.json - if-no-files-found: ignore - retention-days: 7 - - run: chmod +x agentic-commerce/bin/ci-smoke.sh - - run: composer ci - working-directory: agentic-commerce - env: - SHOPWARE_PROJECT_DIR: ${{ github.workspace }}/shopware + - name: Every job is aggregated by its workflow's gate + run: bin/ci-assert-gate-coverage.sh .github/workflows/*.yml - # The early warning that used to be spread across every gate job: does this plugin still build - # against the SDK's moving main branch, before that branch becomes a release the plugin pins? - # - # Deliberately the only job that stages an SDK checkout and relabels it as the pinned version, - # and deliberately non-blocking. A path repository pointed at main and labelled `0.0.7` tests a - # branch wearing a release's version number, which is not what any merchant installs -- and - # when main moved to require a newer `core`, that relabelling turned this repository's main red - # for five days and would have done the same to every open pull request at once. The warning is - # worth having; it is not worth blocking merges on, so it is absent from `expected_checks` and - # from `validation-gate`. Read it, and open an SDK issue -- do not let it stop a merge. - sdk-main-compatibility: - runs-on: ubuntu-latest - continue-on-error: true - needs: - - verify-main-validation - if: needs.verify-main-validation.outputs.run_full_matrix == 'true' - steps: - - uses: actions/checkout@v6 - with: - path: agentic-commerce - - uses: shivammathur/setup-php@v2 - with: - php-version: '8.2' - coverage: none - - uses: actions/checkout@v6 - with: - repository: agentic-commerce-alliance/ucp-php-sdk - ref: ${{ env.UCP_SDK_REF }} - path: ucp-php-sdk - - uses: actions/checkout@v6 - with: - repository: shopware/shopware - ref: ${{ env.SHOPWARE_66_REF }} - path: shopware - - run: composer install --no-dev --no-scripts --no-interaction --prefer-dist - working-directory: shopware - - name: Configure private SDK path repositories - run: | - composer config repositories.ucp-sdk-core '{"type":"path","url":"../ucp-php-sdk/packages/core","options":{"symlink":false,"versions":{"ucp-php-sdk/core":"0.0.7"}}}' - composer config repositories.ucp-sdk-symfony '{"type":"path","url":"../ucp-php-sdk/packages/symfony-bundle","options":{"symlink":false,"versions":{"ucp-php-sdk/symfony-bundle":"0.0.7"}}}' - working-directory: agentic-commerce - - run: composer install --no-interaction --prefer-dist - working-directory: agentic-commerce - - run: composer ci - working-directory: agentic-commerce - env: - SHOPWARE_PROJECT_DIR: ${{ github.workspace }}/shopware + php: + uses: ./.github/workflows/php.yml + with: + sdk_ref: ${{ inputs.sdk_ref }} + shopware_65_ref: ${{ inputs.shopware_65_ref }} + shopware_66_ref: ${{ inputs.shopware_66_ref }} + shopware_trunk_ref: ${{ inputs.shopware_trunk_ref }} - # What Shopware trunk will require of this plugin, against the SDK release the plugin pins. - # - # Like every other gate job, the SDK is resolved from Packagist at the pinned versions rather - # than staged from a checkout: a job that blocks merges has to test what merchants install. - # See sdk-main-compatibility above for where the SDK-main signal lives now. - future-compatibility: - runs-on: ubuntu-latest - needs: - - verify-main-validation - if: needs.verify-main-validation.outputs.run_full_matrix == 'true' - steps: - - uses: actions/checkout@v6 - with: - path: agentic-commerce - - uses: shivammathur/setup-php@v2 - with: - php-version: '8.2' - coverage: none - - uses: actions/checkout@v6 - with: - repository: shopware/shopware - ref: ${{ env.SHOPWARE_TRUNK_REF }} - path: shopware - - run: composer install --no-dev --no-scripts --no-interaction --prefer-dist - working-directory: shopware - - run: composer install --no-interaction --prefer-dist - working-directory: agentic-commerce - - name: Install opt-in future compatibility rules - # Do not enable phpstan-shopware's default rules.neon via its extension installer. - run: composer require --dev "shopwarelabs/phpstan-shopware:${PHPSTAN_SHOPWARE_VERSION}" --no-interaction --no-plugins - working-directory: agentic-commerce - - run: php bin/run.php phpstan analyse --memory-limit=1G -c phpstan.neon - working-directory: agentic-commerce - env: - SHOPWARE_PROJECT_DIR: ${{ github.workspace }}/shopware + admin: + uses: ./.github/workflows/admin.yml + with: + shopware_65_ref: ${{ inputs.shopware_65_ref }} + shopware_66_ref: ${{ inputs.shopware_66_ref }} + shopware_trunk_ref: ${{ inputs.shopware_trunk_ref }} - admin-static: - runs-on: ubuntu-latest - needs: - - verify-main-validation - if: needs.verify-main-validation.outputs.run_full_matrix == 'true' - steps: - - uses: actions/checkout@v6 - with: - path: agentic-commerce - - uses: actions/checkout@v6 - with: - repository: shopware/shopware - ref: ${{ env.SHOPWARE_TRUNK_REF }} - path: shopware - - uses: actions/setup-node@v6 - with: - node-version: '24' - cache: npm - cache-dependency-path: agentic-commerce/package-lock.json - - run: npm ci --no-audit --no-fund - working-directory: agentic-commerce - - run: npm run ci:admin-static - working-directory: agentic-commerce - env: - ADMIN_PATH: ${{ github.workspace }}/shopware/src/Administration/Resources/app/administration + storefront: + uses: ./.github/workflows/storefront.yml + with: + shopware_65_ref: ${{ inputs.shopware_65_ref }} + shopware_66_ref: ${{ inputs.shopware_66_ref }} + shopware_trunk_ref: ${{ inputs.shopware_trunk_ref }} # Builds the archive the way a Store release does, installs it on 6.5, 6.6 and trunk, and leaves # it as the run's downloadable SwagAgenticCommerce artifact. A pull request build carries its PR # number in the extension label, so an installed copy never passes for the release. zip-artifact: - needs: - - verify-main-validation - if: needs.verify-main-validation.outputs.run_full_matrix == 'true' permissions: contents: read uses: ./.github/workflows/package-zip.yml with: label_stamp: ${{ github.event_name == 'pull_request' && 'pull-request' || 'none' }} - shopware-matrix: - runs-on: ubuntu-latest - # The 6.5 lane checks out the head of the 6.5.x branch, which is a 6.5.8 patch -- the - # oldest release composer.json accepts. Name the check after that floor so it is - # visible that the lowest supported version is the one under test. - name: shopware-matrix (${{ matrix.lane == '6.5.x' && '6.5.8' || matrix.lane }}) - timeout-minutes: 30 - needs: - - verify-main-validation - if: needs.verify-main-validation.outputs.run_full_matrix == 'true' - strategy: - fail-fast: false - matrix: - include: - - lane: '6.5.x' - - lane: '6.6.x' - # The newest release line without connect-time MCP toolsets: the only one where the - # UCP tools reach /ucp/mcp through AdvertiseUcpToolsWithoutToolsetPinningPass. - - lane: '6.7.14.x' - - lane: 'trunk' - steps: - - uses: actions/checkout@v6 - with: - path: agentic-commerce - - uses: actions/checkout@v6 - with: - repository: shopware/shopware - ref: ${{ matrix.lane == '6.5.x' && env.SHOPWARE_65_REF || matrix.lane == '6.6.x' && env.SHOPWARE_66_REF || matrix.lane == '6.7.14.x' && env.SHOPWARE_6714_REF || env.SHOPWARE_TRUNK_REF }} - path: shopware - - uses: actions/setup-node@v6 - with: - node-version: '24' - cache: npm - cache-dependency-path: agentic-commerce/package-lock.json - - run: npm ci --no-audit --no-fund - working-directory: agentic-commerce - - run: chmod +x agentic-commerce/bin/ci-smoke.sh agentic-commerce/bin/ci-write-compose.sh - - run: agentic-commerce/bin/ci-write-compose.sh "$GITHUB_WORKSPACE/shopware" "${{ matrix.lane }}" - - run: agentic-commerce/bin/ci-smoke.sh "$GITHUB_WORKSPACE/shopware" - env: - SHOPWARE_REF: ${{ matrix.lane }} - # Run the integration and functional suites on every shopware-matrix lane. ci-smoke - # installs Shopware's dev deps so the suites run on the lane's own phpunit - # (6.5->9.x, 6.6->10.x, trunk->11.x; Shopware's test base classes are coupled - # to the phpunit major), covering lane-specific behavior everywhere the smoke runs. - CI_SMOKE_RUN_PHPUNIT: '1' - CI_COMPOSER_AUDIT_OUTPUT: ${{ github.workspace }}/composer-audit.json - - name: Upload Composer advisory report - if: always() - uses: actions/upload-artifact@v6 - with: - name: composer-audit-${{ matrix.lane }} - path: composer-audit.json - if-no-files-found: ignore - retention-days: 7 - - run: npm run test:e2e:ucp -- --reporter=list - working-directory: agentic-commerce - env: - BASE_URL: http://localhost:8000 - SHOPWARE_REF: ${{ matrix.lane }} - - composer-security-report: - name: composer-security-report - runs-on: ubuntu-latest - needs: - - verify-main-validation - - php-quality - - shopware-matrix - if: always() && needs.verify-main-validation.outputs.run_full_matrix == 'true' - steps: - - uses: actions/checkout@v6 - with: - path: agentic-commerce - - name: Download Composer advisory reports - uses: actions/download-artifact@v7 - continue-on-error: true - with: - pattern: composer-audit-* - path: composer-audits - - name: Summarize Composer security advisories - continue-on-error: true - run: bash agentic-commerce/bin/ci-composer-audit-report.sh composer-audits - - mysql84-install: - runs-on: ubuntu-latest - timeout-minutes: 30 - needs: - - verify-main-validation - if: needs.verify-main-validation.outputs.run_full_matrix == 'true' - steps: - - uses: actions/checkout@v6 - with: - path: agentic-commerce - - uses: actions/checkout@v6 - with: - repository: shopware/shopware - ref: ${{ env.SHOPWARE_TRUNK_REF }} - path: shopware - - run: chmod +x agentic-commerce/bin/ci-smoke.sh agentic-commerce/bin/ci-write-compose.sh - # Provision the stack on MySQL 8.4 (not the default MariaDB) and run the - # plugin install/migrations only. MySQL 8.4 rejects a foreign key that - # references a prefix of a composite key, so this lane catches migration - # regressions like the order (id, version_id) FK that MariaDB accepts. - - run: agentic-commerce/bin/ci-write-compose.sh "$GITHUB_WORKSPACE/shopware" trunk - env: - CI_DB_FLAVOR: mysql84 - - run: agentic-commerce/bin/ci-smoke.sh "$GITHUB_WORKSPACE/shopware" - env: - SHOPWARE_REF: trunk - CI_DB_FLAVOR: mysql84 - CI_SMOKE_BOOTSTRAP_ONLY: '1' - - admin-matrix: - runs-on: ubuntu-latest - name: admin-matrix (${{ matrix.lane == '6.5.x' && '6.5.8' || matrix.lane }}, ${{ matrix.mode }}) - timeout-minutes: 45 + acceptance: + uses: ./.github/workflows/acceptance.yml + with: + lanes: '["trunk"]' + run_changed: true + shopware_65_ref: ${{ inputs.shopware_65_ref }} + shopware_66_ref: ${{ inputs.shopware_66_ref }} + shopware_trunk_ref: ${{ inputs.shopware_trunk_ref }} + + # The 6.5 and 6.6 acceptance lanes. A pull request opts in with the run/acceptance-full label; + # every push and dispatch runs them, so main and a release candidate are validated on all lanes. + acceptance-full: + if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run/acceptance-full') + uses: ./.github/workflows/acceptance.yml + with: + lanes: '["6.5.x", "6.6.x"]' + skip_static: true + shopware_65_ref: ${{ inputs.shopware_65_ref }} + shopware_66_ref: ${{ inputs.shopware_66_ref }} + shopware_trunk_ref: ${{ inputs.shopware_trunk_ref }} + + # One report for the run: merges the blobs of both acceptance calls. + acceptance-report: needs: - - verify-main-validation - if: needs.verify-main-validation.outputs.run_full_matrix == 'true' - strategy: - fail-fast: false - matrix: - include: - - lane: '6.5.x' - mode: webpack - - lane: '6.6.x' - mode: webpack - - lane: '6.6.x' - mode: vite - - lane: 'trunk' - mode: vite - steps: - - uses: actions/checkout@v6 - with: - path: agentic-commerce - - uses: actions/checkout@v6 - with: - repository: shopware/shopware - ref: ${{ matrix.lane == '6.5.x' && env.SHOPWARE_65_REF || matrix.lane == '6.6.x' && env.SHOPWARE_66_REF || env.SHOPWARE_TRUNK_REF }} - path: shopware - - run: npm ci --no-audit --no-fund - working-directory: agentic-commerce - - name: Cache Playwright browsers - id: playwright-cache - uses: actions/cache@v5 - with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ hashFiles('agentic-commerce/package-lock.json') }} - - name: Install Playwright browser + system deps (cache miss) - if: steps.playwright-cache.outputs.cache-hit != 'true' - run: npx playwright install chromium --with-deps - working-directory: agentic-commerce - - name: Install Playwright system deps (cached browser) - if: steps.playwright-cache.outputs.cache-hit == 'true' - run: npx playwright install-deps chromium - working-directory: agentic-commerce - - run: chmod +x agentic-commerce/bin/ci-smoke.sh agentic-commerce/bin/ci-admin-smoke.sh agentic-commerce/bin/ci-write-compose.sh - - run: agentic-commerce/bin/ci-write-compose.sh "$GITHUB_WORKSPACE/shopware" "${{ matrix.lane }}" - - run: agentic-commerce/bin/ci-admin-smoke.sh "$GITHUB_WORKSPACE/shopware" "${{ matrix.mode }}" - env: - SHOPWARE_REF: ${{ matrix.lane }} - CI_ADMIN_BOOTSTRAP_MODE: cold - CI_ADMIN_BROWSER_VALIDATE: 1 - CI_ADMIN_KEEP_STACK: 0 - - storefront-matrix: + - acceptance + - acceptance-full + if: ${{ !cancelled() }} runs-on: ubuntu-latest - name: storefront-matrix (${{ matrix.lane == '6.5.x' && '6.5.8' || matrix.lane }}) - timeout-minutes: 45 - needs: - - verify-main-validation - if: needs.verify-main-validation.outputs.run_full_matrix == 'true' - strategy: - fail-fast: false - matrix: - include: - - lane: '6.5.x' - - lane: '6.6.x' - - lane: 'trunk' steps: - uses: actions/checkout@v6 - with: - path: agentic-commerce - - uses: actions/checkout@v6 - with: - repository: shopware/shopware - ref: ${{ matrix.lane == '6.5.x' && env.SHOPWARE_65_REF || matrix.lane == '6.6.x' && env.SHOPWARE_66_REF || env.SHOPWARE_TRUNK_REF }} - path: shopware - - uses: actions/setup-node@v6 - with: - node-version: '24' - cache: npm - cache-dependency-path: agentic-commerce/package-lock.json - - run: npm ci --no-audit --no-fund - working-directory: agentic-commerce - - name: Cache Playwright browsers - id: playwright-cache - uses: actions/cache@v5 - with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ hashFiles('agentic-commerce/package-lock.json') }} - - name: Install Playwright browser + system deps (cache miss) - if: steps.playwright-cache.outputs.cache-hit != 'true' - run: npx playwright install chromium --with-deps - working-directory: agentic-commerce - - name: Install Playwright system deps (cached browser) - if: steps.playwright-cache.outputs.cache-hit == 'true' - run: npx playwright install-deps chromium - working-directory: agentic-commerce - - run: chmod +x agentic-commerce/bin/ci-smoke.sh agentic-commerce/bin/ci-storefront-smoke.sh agentic-commerce/bin/ci-write-compose.sh - - run: agentic-commerce/bin/ci-write-compose.sh "$GITHUB_WORKSPACE/shopware" "${{ matrix.lane }}" - - run: agentic-commerce/bin/ci-storefront-smoke.sh "$GITHUB_WORKSPACE/shopware" - env: - SHOPWARE_REF: ${{ matrix.lane }} - CI_STOREFRONT_BOOTSTRAP_MODE: cold - CI_STOREFRONT_KEEP_STACK: 0 + - uses: ./.github/actions/acceptance-report validation-gate: - name: validation-gate - runs-on: ubuntu-latest + # store-release.yml requires this job, by this name, in a push run on main. A manual run reports + # as dispatch-gate, so it never posts a validation-gate check on a pull request's head. + name: ${{ github.event_name == 'workflow_dispatch' && 'dispatch-gate' || 'validation-gate' }} + # always(), not !cancelled(): a skipped gate would count as passing. if: always() needs: - - verify-main-validation - shell-lint - - php-quality - - future-compatibility - - admin-static + - docs-lint + - workflow-lint + - php + - admin + - storefront - zip-artifact - - shopware-matrix - - mysql84-install - - admin-matrix - - storefront-matrix + - acceptance + - acceptance-full + - acceptance-report + runs-on: ubuntu-latest steps: - - name: Require complete validation - env: - RUN_FULL_MATRIX: ${{ needs.verify-main-validation.outputs.run_full_matrix }} - VERIFY_RESULT: ${{ needs.verify-main-validation.result }} - SHELL_RESULT: ${{ needs.shell-lint.result }} - PHP_RESULT: ${{ needs.php-quality.result }} - FUTURE_COMPATIBILITY_RESULT: ${{ needs.future-compatibility.result }} - ADMIN_STATIC_RESULT: ${{ needs.admin-static.result }} - ZIP_ARTIFACT_RESULT: ${{ needs.zip-artifact.result }} - SHOPWARE_RESULT: ${{ needs.shopware-matrix.result }} - MYSQL84_RESULT: ${{ needs.mysql84-install.result }} - ADMIN_RESULT: ${{ needs.admin-matrix.result }} - STOREFRONT_RESULT: ${{ needs.storefront-matrix.result }} - run: | - set -euo pipefail - - if [[ "${VERIFY_RESULT}" != "success" || "${SHELL_RESULT}" != "success" ]]; then - echo "Validation setup or shell lint failed." >&2 - exit 1 - fi - - if [[ "${RUN_FULL_MATRIX}" == "false" ]]; then - echo "The exact merged PR checks were already verified." - exit 0 - fi - - if [[ "${RUN_FULL_MATRIX}" != "true" ]]; then - echo "Unable to determine whether the full validation matrix was required." >&2 - exit 1 - fi - - for result in \ - "${PHP_RESULT}" \ - "${FUTURE_COMPATIBILITY_RESULT}" \ - "${ADMIN_STATIC_RESULT}" \ - "${ZIP_ARTIFACT_RESULT}" \ - "${SHOPWARE_RESULT}" \ - "${MYSQL84_RESULT}" \ - "${ADMIN_RESULT}" \ - "${STOREFRONT_RESULT}" - do - if [[ "${result}" != "success" ]]; then - echo "A required validation job did not succeed: ${result}" >&2 - exit 1 - fi - done - - echo "All required validation jobs succeeded." + - uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # v1.3.0 + with: + jobs: ${{ toJSON(needs) }} + # acceptance-full may be skipped only on a pull request without the label. + allowed-skips: ${{ github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'run/acceptance-full') && '["acceptance-full"]' || '[]' }} \ No newline at end of file diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 00000000..76231db9 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,60 @@ +name: Nightly + +# The layer workflows once a day on main, with every acceptance lane repeated three times, so +# breakage from the moving Shopware branches shows up between merges. A test that only passes on a +# retry fails the nightly run; on pull requests it is only listed as flaky in the run summary. +# store-release.yml reads only ci.yml push runs, so nightly-gate never makes a commit releasable. + +on: + schedule: + - cron: '30 2 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + # Includes phpstan-trunk, against SHOPWARE_TRUNK_REF. + php: + uses: ./.github/workflows/php.yml + + admin: + uses: ./.github/workflows/admin.yml + + storefront: + uses: ./.github/workflows/storefront.yml + + zip-artifact: + uses: ./.github/workflows/package-zip.yml + + acceptance: + uses: ./.github/workflows/acceptance.yml + with: + lanes: '["6.5.x", "6.6.x", "trunk"]' + repeat_each: 3 + fail_on_flaky: true + + acceptance-report: + needs: + - acceptance + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/acceptance-report + + nightly-gate: + # always(), not !cancelled(): a skipped gate would count as passing. + if: always() + needs: + - php + - admin + - storefront + - zip-artifact + - acceptance + - acceptance-report + runs-on: ubuntu-latest + steps: + - uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # v1.3.0 + with: + jobs: ${{ toJSON(needs) }} \ No newline at end of file diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml new file mode 100644 index 00000000..510c5a5f --- /dev/null +++ b/.github/workflows/php.yml @@ -0,0 +1,344 @@ +name: PHP + +# PHP quality, the Shopware lanes (smoke incl. the strict signature stage, integration and +# functional suites, UCP e2e), the MySQL 8.4 install and the advisory checks. + +on: + workflow_call: + inputs: + sdk_ref: + type: string + shopware_65_ref: + type: string + shopware_66_ref: + type: string + shopware_trunk_ref: + type: string + workflow_dispatch: + +# A caller's env does not reach a called workflow, so every layer resolves the refs itself. +env: + # Compatibility lanes intentionally exercise Shopware versions with known + # advisories. Keep dependency resolution running, then report advisories + # without making them the test result. Composer 2.9 uses the first variable; + # newer Composer versions use the policy variable. + COMPOSER_NO_SECURITY_BLOCKING: '1' + COMPOSER_POLICY_ADVISORIES_BLOCK: '0' + UCP_SDK_REF: ${{ inputs.sdk_ref || vars.UCP_SDK_REF || 'main' }} + SHOPWARE_65_REF: ${{ inputs.shopware_65_ref || vars.SHOPWARE_65_REF || '6.5.x' }} + SHOPWARE_66_REF: ${{ inputs.shopware_66_ref || vars.SHOPWARE_66_REF || '6.6.x' }} + SHOPWARE_TRUNK_REF: ${{ inputs.shopware_trunk_ref || vars.SHOPWARE_TRUNK_REF || 'trunk' }} + SHOPWARE_6714_REF: ${{ vars.SHOPWARE_6714_REF || '6.7.14.x' }} + PHPSTAN_SHOPWARE_VERSION: '0.2.4' + +permissions: + contents: read + +jobs: + php-quality: + runs-on: ubuntu-latest + name: php-quality (${{ matrix.lane }}) + strategy: + fail-fast: false + matrix: + include: + - lane: 'php-8.1' + php-version: '8.1' + - lane: 'php-8.2' + php-version: '8.2' + # Future-compatibility rules inspect the upcoming core API annotations, which are + # available only on trunk. + - lane: 'trunk' + php-version: '8.2' + shopware-ref: 'trunk' + # 6.7.11.x: core still ships Defaults::SALES_CHANNEL_TYPE_AGENTIC_COMMERCE here + # (present only in 6.7.10-6.7.11), so this lane keeps exercising the "core owns the + # feature" coexistence path. Moving branch ref -> always the latest 6.7.11 patch. + - lane: '6.7.11' + php-version: '8.2' + shopware-ref: '6.7.11.x' + # 6.7.12.x: core removed that constant and handed the feature back to the plugin, so + # this lane proves the "plugin re-owns the feature" path on the 6.7 framework. Moving + # branch ref -> always the latest 6.7.12 patch. + - lane: '6.7.12' + php-version: '8.2' + shopware-ref: '6.7.12.x' + steps: + - uses: actions/checkout@v6 + with: + path: agentic-commerce + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: none + - uses: actions/checkout@v6 + with: + repository: shopware/shopware + ref: ${{ matrix.shopware-ref || matrix.php-version == '8.1' && env.SHOPWARE_65_REF || env.SHOPWARE_66_REF }} + path: shopware + - run: composer install --no-dev --no-scripts --no-interaction --prefer-dist + working-directory: shopware + - run: composer install --no-interaction --prefer-dist + working-directory: agentic-commerce + - name: Install opt-in future compatibility rules + if: matrix.php-version != '8.1' + # Install without plugins so phpstan-shopware's default rules.neon is not registered. + # The package requires PHP 8.2, while this plugin remains compatible with PHP 8.1. + run: composer require --dev shopwarelabs/phpstan-shopware:0.2.4 --no-interaction --no-plugins --ignore-platform-req=php + working-directory: agentic-commerce + - name: Capture plugin Composer advisory report + if: matrix.lane == 'php-8.2' + working-directory: agentic-commerce + run: | + audit_file="${GITHUB_WORKSPACE}/composer-audit-plugin.json" + audit_status=0 + + if composer audit --no-dev --format=json > "${audit_file}"; then + audit_status=0 + else + audit_status=$? + fi + + if [[ ! -s "${audit_file}" ]]; then + if [[ "${audit_status}" -eq 0 ]]; then + printf '{"advisories":{},"abandoned":[]}\n' > "${audit_file}" + else + printf '{"error":"composer audit produced no JSON","exitCode":%d}\n' "${audit_status}" > "${audit_file}" + fi + fi + + echo "Composer audit report written to ${audit_file} (exit ${audit_status})." + - name: Upload plugin Composer advisory report + if: always() && matrix.lane == 'php-8.2' + uses: actions/upload-artifact@v6 + with: + name: composer-audit-plugin + path: composer-audit-plugin.json + if-no-files-found: ignore + retention-days: 7 + - run: chmod +x agentic-commerce/bin/ci-smoke.sh + - run: composer ci + working-directory: agentic-commerce + env: + SHOPWARE_PROJECT_DIR: ${{ github.workspace }}/shopware + + # What Shopware trunk will require of this plugin, against the SDK release the plugin pins. + # + # Like every other gate job, the SDK is resolved from Packagist at the pinned versions rather + # than staged from a checkout: a job that blocks merges has to test what merchants install. + # See sdk-main-compatibility below for where the SDK-main signal lives now. + phpstan-trunk: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + path: agentic-commerce + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + coverage: none + - uses: actions/checkout@v6 + with: + repository: shopware/shopware + ref: ${{ env.SHOPWARE_TRUNK_REF }} + path: shopware + - run: composer install --no-dev --no-scripts --no-interaction --prefer-dist + working-directory: shopware + - run: composer install --no-interaction --prefer-dist + working-directory: agentic-commerce + - name: Install opt-in future compatibility rules + # Do not enable phpstan-shopware's default rules.neon via its extension installer. + run: composer require --dev "shopwarelabs/phpstan-shopware:${PHPSTAN_SHOPWARE_VERSION}" --no-interaction --no-plugins + working-directory: agentic-commerce + - run: php bin/run.php phpstan analyse --memory-limit=1G -c phpstan.neon + working-directory: agentic-commerce + env: + SHOPWARE_PROJECT_DIR: ${{ github.workspace }}/shopware + + # The early warning that used to be spread across every gate job: does this plugin still build + # against the SDK's moving main branch, before that branch becomes a release the plugin pins? + # + # Deliberately the only job that stages an SDK checkout and relabels it as the pinned version, + # and deliberately non-blocking. A path repository pointed at main and labelled `0.0.7` tests a + # branch wearing a release's version number, which is not what any merchant installs -- and + # when main moved to require a newer `core`, that relabelling turned this repository's main red + # for five days and would have done the same to every open pull request at once. The warning is + # worth having; it is not worth blocking merges on, so the job is continue-on-error. + # Read it, and open an SDK issue -- do not let it stop a merge. + sdk-main-compatibility: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v6 + with: + path: agentic-commerce + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + coverage: none + - uses: actions/checkout@v6 + with: + repository: agentic-commerce-alliance/ucp-php-sdk + ref: ${{ env.UCP_SDK_REF }} + path: ucp-php-sdk + - uses: actions/checkout@v6 + with: + repository: shopware/shopware + ref: ${{ env.SHOPWARE_66_REF }} + path: shopware + - run: composer install --no-dev --no-scripts --no-interaction --prefer-dist + working-directory: shopware + - name: Configure private SDK path repositories + run: | + composer config repositories.ucp-sdk-core '{"type":"path","url":"../ucp-php-sdk/packages/core","options":{"symlink":false,"versions":{"ucp-php-sdk/core":"0.0.7"}}}' + composer config repositories.ucp-sdk-symfony '{"type":"path","url":"../ucp-php-sdk/packages/symfony-bundle","options":{"symlink":false,"versions":{"ucp-php-sdk/symfony-bundle":"0.0.7"}}}' + working-directory: agentic-commerce + - run: composer install --no-interaction --prefer-dist + working-directory: agentic-commerce + - run: composer ci + working-directory: agentic-commerce + env: + SHOPWARE_PROJECT_DIR: ${{ github.workspace }}/shopware + + # Advisory: reports public API breaks between the base and this change without blocking it. On a + # pull request HEAD is the merge commit, so HEAD^ is the base branch. + bc-check: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 2 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + coverage: none + - run: composer install --no-interaction --prefer-dist + - run: composer bc-check + + shopware-matrix: + runs-on: ubuntu-latest + # The 6.5 lane checks out the head of the 6.5.x branch, which is a 6.5.8 patch -- the + # oldest release composer.json accepts. Name the check after that floor so it is + # visible that the lowest supported version is the one under test. + name: shopware-matrix (${{ matrix.lane == '6.5.x' && '6.5.8' || matrix.lane }}) + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - lane: '6.5.x' + - lane: '6.6.x' + # The newest release line without connect-time MCP toolsets: the only one where the + # UCP tools reach /ucp/mcp through AdvertiseUcpToolsWithoutToolsetPinningPass. + - lane: '6.7.14.x' + - lane: 'trunk' + steps: + - uses: actions/checkout@v6 + with: + path: agentic-commerce + - uses: actions/checkout@v6 + with: + repository: shopware/shopware + ref: ${{ matrix.lane == '6.5.x' && env.SHOPWARE_65_REF || matrix.lane == '6.6.x' && env.SHOPWARE_66_REF || matrix.lane == '6.7.14.x' && env.SHOPWARE_6714_REF || env.SHOPWARE_TRUNK_REF }} + path: shopware + - uses: actions/setup-node@v6 + with: + node-version: '24' + cache: npm + cache-dependency-path: agentic-commerce/package-lock.json + - run: npm ci --no-audit --no-fund + working-directory: agentic-commerce + - run: chmod +x agentic-commerce/bin/ci-smoke.sh agentic-commerce/bin/ci-write-compose.sh + - run: agentic-commerce/bin/ci-write-compose.sh "$GITHUB_WORKSPACE/shopware" "${{ matrix.lane }}" + - run: agentic-commerce/bin/ci-smoke.sh "$GITHUB_WORKSPACE/shopware" + env: + SHOPWARE_REF: ${{ matrix.lane }} + # Run the integration and functional suites on every shopware-matrix lane. ci-smoke + # installs Shopware's dev deps so the suites run on the lane's own phpunit + # (6.5->9.x, 6.6->10.x, trunk->11.x; Shopware's test base classes are coupled + # to the phpunit major), covering lane-specific behavior everywhere the smoke runs. + CI_SMOKE_RUN_PHPUNIT: '1' + CI_COMPOSER_AUDIT_OUTPUT: ${{ github.workspace }}/composer-audit.json + # The e2e step below runs against this stack. + CI_SMOKE_KEEP_STACK: '1' + - name: Upload Composer advisory report + if: always() + uses: actions/upload-artifact@v6 + with: + name: composer-audit-${{ matrix.lane }} + path: composer-audit.json + if-no-files-found: ignore + retention-days: 7 + - run: npm run test:e2e:ucp -- --reporter=list + working-directory: agentic-commerce + env: + BASE_URL: http://localhost:8000 + SHOPWARE_REF: ${{ matrix.lane }} + + # Non-blocking through continue-on-error (see CONTRIBUTING.md, Composer advisory reporting). + composer-security-report: + name: composer-security-report + runs-on: ubuntu-latest + continue-on-error: true + needs: + - php-quality + - shopware-matrix + # Runs after failed lanes too, to report whatever audits they uploaded. + if: always() + steps: + - uses: actions/checkout@v6 + with: + path: agentic-commerce + - name: Download Composer advisory reports + uses: actions/download-artifact@v7 + continue-on-error: true + with: + pattern: composer-audit-* + path: composer-audits + - name: Summarize Composer security advisories + continue-on-error: true + run: bash agentic-commerce/bin/ci-composer-audit-report.sh composer-audits + + mysql84-install: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + with: + path: agentic-commerce + - uses: actions/checkout@v6 + with: + repository: shopware/shopware + ref: ${{ env.SHOPWARE_TRUNK_REF }} + path: shopware + - run: chmod +x agentic-commerce/bin/ci-smoke.sh agentic-commerce/bin/ci-write-compose.sh + # Provision the stack on MySQL 8.4 (not the default MariaDB) and run the + # plugin install/migrations only. MySQL 8.4 rejects a foreign key that + # references a prefix of a composite key, so this lane catches migration + # regressions like the order (id, version_id) FK that MariaDB accepts. + - run: agentic-commerce/bin/ci-write-compose.sh "$GITHUB_WORKSPACE/shopware" trunk + env: + CI_DB_FLAVOR: mysql84 + - run: agentic-commerce/bin/ci-smoke.sh "$GITHUB_WORKSPACE/shopware" + env: + SHOPWARE_REF: trunk + CI_DB_FLAVOR: mysql84 + CI_SMOKE_BOOTSTRAP_ONLY: '1' + + php-check: + # always(), not !cancelled(): a skipped gate would count as passing. + if: always() + needs: + - php-quality + - phpstan-trunk + - sdk-main-compatibility + - bc-check + - shopware-matrix + - composer-security-report + - mysql84-install + runs-on: ubuntu-latest + steps: + - uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # v1.3.0 + with: + jobs: ${{ toJSON(needs) }} \ No newline at end of file diff --git a/.github/workflows/store-release.yml b/.github/workflows/store-release.yml index a5e99910..8fb67a02 100644 --- a/.github/workflows/store-release.yml +++ b/.github/workflows/store-release.yml @@ -39,7 +39,8 @@ jobs: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest permissions: - checks: read + # Job-level permissions replace the workflow's: reading CI's runs needs actions: read here. + actions: read contents: read outputs: version: ${{ steps.metadata.outputs.version }} @@ -92,18 +93,49 @@ jobs: exit 1 fi - gate_id="$( - gh api --paginate "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/check-runs?per_page=100" \ - --jq '.check_runs[] | select(.name == "validation-gate" and .conclusion == "success") | .id' \ - | head -n 1 + # A check run named validation-gate proves nothing: any workflow, dispatch or app can + # create one. Require the newest ci.yml push run on main for this commit to be complete, + # with a successful validation-gate job in its latest attempt. A run still in progress + # fails, so an older green run cannot outvote a newer one. + runs="$( + gh api --paginate \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/ci.yml/runs?head_sha=${GITHUB_SHA}&event=push&branch=main&per_page=100" \ + --jq '.workflow_runs[]' \ + | jq -s --arg sha "${GITHUB_SHA}" --arg repo "${GITHUB_REPOSITORY}" ' + map(select(.event == "push" and .head_branch == "main" and .head_sha == $sha + and .head_repository.full_name == $repo + and (.path | startswith(".github/workflows/ci.yml")))) + | sort_by(.id)' )" - if [[ -z "${gate_id}" ]]; then - echo "The exact main commit has no successful validation-gate check." >&2 + if [[ "$(jq length <<<"${runs}")" -eq 0 ]]; then + echo "No CI push run on main exists for ${GITHUB_SHA}." >&2 exit 1 fi - echo "Validated main HEAD ${GITHUB_SHA} with CI gate ${gate_id}." + if jq -e 'any(.[]; .status != "completed")' <<<"${runs}" >/dev/null; then + echo "CI for ${GITHUB_SHA} is still running. Release once it has finished." >&2 + exit 1 + fi + + run_id="$(jq -r 'last.id' <<<"${runs}")" + attempt="$(jq -r 'last.run_attempt' <<<"${runs}")" + + # filter=latest lists each job's latest attempt. The gate needs every other job, so it is + # re-run whenever any of them is. + gates="$( + gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?filter=latest&per_page=100" \ + --jq '.jobs[] | select(.name == "validation-gate")' \ + | jq -s . + )" + + if ! jq -e --argjson attempt "${attempt}" \ + 'length == 1 and .[0].conclusion == "success" and .[0].run_attempt == $attempt' <<<"${gates}" >/dev/null; then + echo "CI run ${run_id} (attempt ${attempt}) has no successful validation-gate job." >&2 + exit 1 + fi + + echo "Validated main HEAD ${GITHUB_SHA} with CI run ${run_id} (attempt ${attempt})." release-package: name: Build and install-test the release package diff --git a/.github/workflows/storefront.yml b/.github/workflows/storefront.yml new file mode 100644 index 00000000..978b1b75 --- /dev/null +++ b/.github/workflows/storefront.yml @@ -0,0 +1,84 @@ +name: Storefront + +# The per-lane storefront build gates. + +on: + workflow_call: + inputs: + shopware_65_ref: + type: string + shopware_66_ref: + type: string + shopware_trunk_ref: + type: string + workflow_dispatch: + +# A caller's env does not reach a called workflow, so every layer resolves the refs itself. +env: + SHOPWARE_65_REF: ${{ inputs.shopware_65_ref || vars.SHOPWARE_65_REF || '6.5.x' }} + SHOPWARE_66_REF: ${{ inputs.shopware_66_ref || vars.SHOPWARE_66_REF || '6.6.x' }} + SHOPWARE_TRUNK_REF: ${{ inputs.shopware_trunk_ref || vars.SHOPWARE_TRUNK_REF || 'trunk' }} + +permissions: + contents: read + +jobs: + storefront-matrix: + runs-on: ubuntu-latest + name: storefront-matrix (${{ matrix.lane == '6.5.x' && '6.5.8' || matrix.lane }}) + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - lane: '6.5.x' + - lane: '6.6.x' + - lane: 'trunk' + steps: + - uses: actions/checkout@v6 + with: + path: agentic-commerce + - uses: actions/checkout@v6 + with: + repository: shopware/shopware + ref: ${{ matrix.lane == '6.5.x' && env.SHOPWARE_65_REF || matrix.lane == '6.6.x' && env.SHOPWARE_66_REF || env.SHOPWARE_TRUNK_REF }} + path: shopware + - uses: actions/setup-node@v6 + with: + node-version: '24' + cache: npm + cache-dependency-path: agentic-commerce/package-lock.json + - run: npm ci --no-audit --no-fund + working-directory: agentic-commerce + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('agentic-commerce/package-lock.json') }} + - name: Install Playwright browser + system deps (cache miss) + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: npx playwright install chromium --with-deps + working-directory: agentic-commerce + - name: Install Playwright system deps (cached browser) + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: npx playwright install-deps chromium + working-directory: agentic-commerce + - run: chmod +x agentic-commerce/bin/ci-smoke.sh agentic-commerce/bin/ci-storefront-smoke.sh agentic-commerce/bin/ci-write-compose.sh + - run: agentic-commerce/bin/ci-write-compose.sh "$GITHUB_WORKSPACE/shopware" "${{ matrix.lane }}" + - run: agentic-commerce/bin/ci-storefront-smoke.sh "$GITHUB_WORKSPACE/shopware" + env: + SHOPWARE_REF: ${{ matrix.lane }} + CI_STOREFRONT_BOOTSTRAP_MODE: cold + CI_STOREFRONT_KEEP_STACK: 0 + + storefront-check: + # always(), not !cancelled(): a skipped gate would count as passing. + if: always() + needs: + - storefront-matrix + runs-on: ubuntu-latest + steps: + - uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # v1.3.0 + with: + jobs: ${{ toJSON(needs) }} \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4a07beb5..cc106d01 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -178,8 +178,8 @@ through the centralized reporting flow: `storefront-matrix`, MySQL, or individual smoke jobs. A nonzero `composer audit` status can also represent abandoned packages; inspect the JSON `advisories` data instead of treating the exit code as proof of a security advisory. -- Keep `composer-security-report` outside `validation-gate`. Missing, malformed, or known-vulnerable - compatibility reports must remain visible without blocking functional validation. +- Keep `composer-security-report` non-blocking (job-level `continue-on-error`). Missing, malformed, + or known-vulnerable compatibility reports must remain visible without failing `validation-gate`. Do not reuse a complete installed Shopware tree across these jobs. Lanes use different Shopware versions and administration build modes, and the tests mutate dependencies, assets, databases, and diff --git a/bin/ci-acceptance-report.sh b/bin/ci-acceptance-report.sh new file mode 100755 index 00000000..21b9fc5f --- /dev/null +++ b/bin/ci-acceptance-report.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash + +# Merges the acceptance blob reports of a run into one HTML report and writes the failed and flaky +# tests to the step summary. Test failures are reported, not returned: the acceptance jobs already +# fail for them. This fails only when there is nothing to merge or the merge writes no report. +# +# Usage: bin/ci-acceptance-report.sh + +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "Usage: bin/ci-acceptance-report.sh " >&2 + exit 2 +fi + +PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +mkdir -p "$1" "$2" +blob_dir="$(cd "$1" && pwd)" +report_dir="$(cd "$2" && pwd)" + +if ! compgen -G "${blob_dir}/*.zip" >/dev/null; then + echo "FAIL: no blob reports in ${blob_dir}. Did every acceptance job fail before running the suite?" >&2 + exit 1 +fi + +# Without -c, merge-reports does not load the suite's config, which would demand APP_URL. +( + cd "${PLUGIN_ROOT}/tests/acceptance" + PLAYWRIGHT_HTML_OUTPUT_DIR="${report_dir}/html" PLAYWRIGHT_HTML_OPEN=never \ + PLAYWRIGHT_JSON_OUTPUT_FILE="${report_dir}/report.json" \ + npx playwright merge-reports --reporter=html,json "${blob_dir}" +) || true + +if [[ ! -s "${report_dir}/report.json" || ! -s "${report_dir}/html/index.html" ]]; then + echo "FAIL: merging the blob reports in ${blob_dir} wrote no report." >&2 + exit 1 +fi + +{ + echo "### Acceptance report" + echo + jq -r '.stats | "\(.expected // 0) passed, \(.unexpected // 0) failed, \(.flaky // 0) flaky, \(.skipped // 0) skipped. The HTML report (tests tagged by lane) is the acceptance-report artifact."' \ + "${report_dir}/report.json" + echo + jq -r ' + [.suites[] | recurse(.suites[]?) | .specs[]? as $spec | $spec.tests[] + | select(.status == "unexpected" or .status == "flaky") + | "| \(if .status == "unexpected" then "failed" else "flaky" end) | \(.projectName) | `\($spec.file):\($spec.line) \($spec.title | gsub("\\|"; "\\|"))` |"] + | unique + | if length == 0 then "No failed or flaky tests." else (["| Status | Project | Test |", "| --- | --- | --- |"] + .)[] end + ' "${report_dir}/report.json" +} | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}" \ No newline at end of file diff --git a/bin/ci-assert-gate-coverage.sh b/bin/ci-assert-gate-coverage.sh new file mode 100755 index 00000000..697241e5 --- /dev/null +++ b/bin/ci-assert-gate-coverage.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +# Fails unless every job of each given workflow is aggregated by that workflow's gate. +# +# Usage: bin/ci-assert-gate-coverage.sh ... +# +# The gate is the one job that runs re-actors/alls-green. It must run with `if: always()` (a skipped +# gate counts as passing), pass `jobs: ${{ toJSON(needs) }}`, and need every other job of its file. +# A job that should not block is made advisory with continue-on-error, not left outside the gate's +# needs. Jobs are compared by id, not by display name. +# +# A reusable workflow (workflow_call) must have a gate, so a skipped job inside it stays visible to +# its caller. Other workflows without a gate (release, notifications) are not checked. +# Needs yq (mikefarah) and jq. + +set -euo pipefail + +# Reusable workflows that need no gate: package-zip.yml has no conditional jobs and is also called by +# store-release.yml, where a gate job would only add a check. +ungated_reusable=(package-zip.yml) + +if [[ $# -eq 0 ]]; then + echo "Usage: bin/ci-assert-gate-coverage.sh ..." >&2 + exit 2 +fi + +status=0 + +for workflow in "$@"; do + workflow_json="$(yq -o=json '.' "${workflow}")" + jobs_json="$(jq '.jobs // {}' <<<"${workflow_json}")" + + mapfile -t gates < <(jq -r ' + to_entries[] + | select([.value.steps[]?.uses // "" | startswith("re-actors/alls-green@")] | any) + | .key' <<<"${jobs_json}") + + if [[ ${#gates[@]} -eq 0 ]]; then + reusable="$(jq -r '.on | type == "object" and has("workflow_call")' <<<"${workflow_json}")" + if [[ "${reusable}" != "true" ]] || [[ " ${ungated_reusable[*]} " == *" $(basename "${workflow}") "* ]]; then + echo "skip: ${workflow} has no gate and needs none." + continue + fi + fi + + if [[ ${#gates[@]} -ne 1 ]]; then + echo "FAIL: ${workflow} needs exactly one re-actors/alls-green gate job, found ${#gates[@]}: ${gates[*]:-none}." >&2 + status=1 + continue + fi + gate="${gates[0]}" + + problems="$(jq -r --arg gate "${gate}" ' + .[$gate] as $g + | ($g.needs // [] | if type == "string" then [.] else . end) as $needs + | (keys - [$gate]) as $jobs + | ([$g.steps[] | select((.uses // "") | startswith("re-actors/alls-green@"))][0].with.jobs // "") as $input + | [ + (if ($g.if // "" | gsub("[${} ]"; "")) != "always()" then "\($gate) must run with if: always()" else empty end), + (if ($input | gsub(" "; "")) != "${{toJSON(needs)}}" then "\($gate) must pass jobs: ${{ toJSON(needs) }} to alls-green" else empty end), + (($jobs - $needs)[] | "job \(.) is missing from \($gate).needs"), + (($needs - $jobs)[] | "\($gate).needs names \(.), which is not a job of this workflow") + ] + | .[]' <<<"${jobs_json}")" + + if [[ -n "${problems}" ]]; then + while IFS= read -r problem; do + echo "FAIL: ${workflow}: ${problem}." >&2 + done <<<"${problems}" + status=1 + continue + fi + + echo "ok: ${workflow}: ${gate} aggregates all $(jq 'keys | length - 1' <<<"${jobs_json}") other job(s)." +done + +exit "${status}" \ No newline at end of file diff --git a/docs/releasing.md b/docs/releasing.md index 40ea8575..12b0d347 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -103,11 +103,11 @@ Every job that blocks a merge resolves both SDK packages **from Packagist at the exception and the only job that stages an SDK checkout: it points a path repository at `UCP_SDK_REF` and relabels that source as the pinned version. -That relabelling is why the exception is `continue-on-error` and absent from both `expected_checks` -and `validation-gate`. A branch wearing a release's version number is not what anyone installs, and -when SDK `main` moved to require a newer `core`, having it on the merge path turned this -repository's `main` red for five days — and would have done the same to every open pull request at -once. Read the job, open an SDK issue; do not let it stop a merge. +That relabelling is why the exception is `continue-on-error`, so it never fails `validation-gate`. A +branch wearing a release's version number is not what anyone installs, and when SDK `main` moved to +require a newer `core`, having it on the merge path turned this repository's `main` red for five +days — and would have done the same to every open pull request at once. Read the job, open an SDK +issue; do not let it stop a merge. `bin/ci-smoke.sh` follows the same rule through `UCP_SDK_SOURCE`, which defaults to `packagist`; only `sdk-main-compatibility` and local manual testing set `path`. diff --git a/tests/acceptance/README.md b/tests/acceptance/README.md index 7a75a014..5438cb14 100644 --- a/tests/acceptance/README.md +++ b/tests/acceptance/README.md @@ -42,6 +42,23 @@ npm test with an opaque error. `npm run test:signer` is the exception: it sets `UCP_SIGNER_ONLY=1`, which drops the check and the wait for the server. +### In CI + +`.github/workflows/acceptance.yml` runs the suite; `ci.yml` and `nightly.yml` call it. + +| When | What runs | +| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| Every pull request | Lint, typecheck and `Signer`, then the gating run on trunk. | +| A pull request with the `run/acceptance-full` label | The 6.5 and 6.6 lanes as well. Adding the label starts a new run. | +| A pull request that changes specs | Every changed spec ten times on trunk, without retries, on two workers. `Setup` and `Signer` run once. | +| Every push to `main` and every manual run | Trunk, 6.5 and 6.6. | +| Nightly | Trunk, 6.5 and 6.6, every test three times. | + +The ten repetitions catch a flaky spec before it lands. Only changed `*.spec.ts` files are repeated; +specs under `Setup`, `Signer` and `KnownBlocked` are not. A changed spec that selects no test, for +example one without a project tag, fails the job. A change to a fixture, page object or service +alone repeats nothing. + ## Projects and tags Projects are routed by tag, the way core does it. A spec opts into a project by carrying its tag in diff --git a/tests/acceptance/fixtures/UcpAclUsers.ts b/tests/acceptance/fixtures/UcpAclUsers.ts index e7d99f46..0a5232b7 100644 --- a/tests/acceptance/fixtures/UcpAclUsers.ts +++ b/tests/acceptance/fixtures/UcpAclUsers.ts @@ -36,27 +36,40 @@ export interface UcpAclUsersTypes { } export const test = base.extend({ - UcpAclUsers: async ({ TestDataService, AdminApiContext, SalesChannelBaseConfig, browser, AdminSession }, use) => { + // Not the worker's AdminSession: as a dependency it makes every worker create the ATS admin user + // up front, and the ATS does not retry core's refresh token conflict on that insert. + UcpAclUsers: async ({ TestDataService, AdminApiContext, SalesChannelBaseConfig, browser }, use) => { const mapping = await readAdminPrivilegeMapping(); const users = new Map>(); const otherPages: Promise[] = []; + const freshAdmin = (): Promise => { + const pending = TestDataService.createUserRetryingTokenConflicts({ admin: true }) + .then(async (user) => { + const loginPage = await createNewAdminPageContext(browser, SalesChannelBaseConfig); + await withoutUpdateNotification(loginPage); + + return loginToAdministration(loginPage, user, AdminApiContext); + }); + otherPages.push(pending); + + return pending; + }; + + // Created on first use, so a test that asks for no role user logs in no extra admin. + let privilegesPage: Promise | undefined; + /** * What the role editor stores for these role keys: the running Administration resolves their * dependencies, including privileges other plugins add to core roles such as * `sales_channel.viewer`. */ const grantedPrivileges = async (roleKeys: string[]): Promise => { - const page = await AdminSession.context.newPage(); - try { - await page.goto(process.env.ADMIN_URL ?? './'); - await expect(page.locator('.sw-admin-menu')).toBeVisible(); - - return await page.evaluate(`Shopware.Service('privileges').getPrivilegesForAdminPrivilegeKeys(${JSON.stringify(roleKeys)})`); - } - finally { - await page.close(); - } + privilegesPage ??= freshAdmin(); + const page = await privilegesPage; + await expect(page.locator('.sw-admin-menu')).toBeVisible(); + + return page.evaluate(`Shopware.Service('privileges').getPrivilegesForAdminPrivilegeKeys(${JSON.stringify(roleKeys)})`); }; const createUser = async (name: string, roleKeys: string[]): Promise<{ user: User, privileges: string[], page: Page }> => { @@ -89,13 +102,7 @@ export const test = base.extend { - const pending = TestDataService.createUserRetryingTokenConflicts({ admin: true }) - .then(async user => loginToAdministration(await createNewAdminPageContext(browser, SalesChannelBaseConfig), user, AdminApiContext)); - otherPages.push(pending); - - return pending; - }, + asFreshAdmin: freshAdmin, asSalesChannelViewer: () => { const pending = createUser('sales_channel.viewer', ['sales_channel.viewer']).then(({ page }) => page); otherPages.push(pending); @@ -112,6 +119,15 @@ export const test = base.extend { + await page.context().route('**/api/_action/update/check', route => route.fulfill({ json: {} })); +} + function aclRoleSuffix(): string { return Math.random().toString(36).slice(2, 8); } diff --git a/tests/acceptance/fixtures/UcpTestData.ts b/tests/acceptance/fixtures/UcpTestData.ts index c8a7a8ae..38387c7d 100644 --- a/tests/acceptance/fixtures/UcpTestData.ts +++ b/tests/acceptance/fixtures/UcpTestData.ts @@ -12,8 +12,9 @@ const skipCleanUp = ['1', 'true'].includes(process.env.ATS_SKIP_CLEANUP ?? ''); const ATS_CLEANUP_ATTEMPTS = 2; -// MariaDB 11.6.2+ snapshot isolation rejects a product delete with error 1020 while other workers write products, and core does not retry it. -// The ATS asserts its first delete batch but returns the last one unchecked, so that response is checked here. +// Retried for write conflicts between workers (MariaDB 11.6.2+ snapshot isolation answers some with error 1020, which core does +// not retry); which write conflicts here has not been captured yet. The ATS asserts its first delete batch but returns the last +// one unchecked, so that response is checked here. async function atsCleanUpRetryingWriteConflict(service: UcpTestDataService): Promise { const cleanUpErrors: unknown[] = []; @@ -30,7 +31,9 @@ async function atsCleanUpRetryingWriteConflict(service: UcpTestDataService): Pro } } - throw new AggregateError(cleanUpErrors, `ATS cleanup failed ${ATS_CLEANUP_ATTEMPTS} times; the first failure is usually the cause.`); + // Playwright reports an error's message and cause, not an AggregateError's errors, so the attempts go into the message. + const attempts = cleanUpErrors.map((error, index) => `attempt ${index + 1}: ${error instanceof Error ? error.message : String(error)}`); + throw new Error(`ATS cleanup failed ${ATS_CLEANUP_ATTEMPTS} times; the first failure is usually the cause.\n${attempts.join('\n')}`); } /** diff --git a/tests/acceptance/known-blockers.ts b/tests/acceptance/known-blockers.ts index d7b4ac76..0abb81d6 100644 --- a/tests/acceptance/known-blockers.ts +++ b/tests/acceptance/known-blockers.ts @@ -73,14 +73,28 @@ export const knownBlockers: KnownBlocker[] = [ key: 'D10', description: 'On Shopware 6.7.12.x core looks discovery files up case-sensitively and ships only agents.md, and the plugin registers no fallback route there, so /AGENTS.md answers 404.', owner: 'dgrothaus-sw', - issueUrl: 'TODO: file the issue for /AGENTS.md on 6.7.12.x', + issueUrl: 'https://github.com/shopware/agentic-commerce/issues/307', reviewBy: '2026-12-31', }, { key: 'D11', description: 'Below Shopware 6.7.10.0 the Validate action builds the product export without its provider, so AbstractProviderValidator skips every agentic validator and Validate reports no errors for a feed whose export fails.', owner: 'dgrothaus-sw', - issueUrl: 'TODO: file the issue for Validate skipping the provider validators', + issueUrl: 'https://github.com/shopware/agentic-commerce/issues/292', + reviewBy: '2026-12-31', + }, + { + key: 'D12', + description: 'A feed channel\'s Save with an empty required provider setting is inconsistent. On 6.5 the first click does nothing visible: validateAgenticCommerceExportConfig() adds the error as a new key, which Vue 2 does not track, so the field is not marked. On a new channel (6.5 and 6.6) the second click saves anyway, because the plugin\'s onSave() override returns early inside the create page\'s $super chain.', + owner: 'alexdumea', + issueUrl: 'https://github.com/shopware/agentic-commerce/issues/309', + reviewBy: '2026-12-31', + }, + { + key: 'D13', + description: 'Before the Meteor select, cancelling the "Apply template" modal on a saved feed channel leaves the cancelled template in the select: onTemplateModalClose() restores the value templateName already has, so the select is not redrawn.', + owner: 'alexdumea', + issueUrl: 'https://github.com/shopware/agentic-commerce/issues/310', reviewBy: '2026-12-31', }, ]; diff --git a/tests/acceptance/page-objects/AgenticCommerceFeedDetail.ts b/tests/acceptance/page-objects/AgenticCommerceFeedDetail.ts index 490c63a0..cc9f847c 100644 --- a/tests/acceptance/page-objects/AgenticCommerceFeedDetail.ts +++ b/tests/acceptance/page-objects/AgenticCommerceFeedDetail.ts @@ -1,5 +1,6 @@ import { expect } from '@playwright/test'; import type { Locator, Page, Response } from '@playwright/test'; +import { FEED_SALES_CHANNEL_TYPE_ID } from '@services/UcpTestDataService'; import { goToAdminRoute } from './adminRoute'; const OPEN_AI_CONFIG_DOMAIN = 'SwagAgenticCommerce.openAiProductExport'; @@ -26,8 +27,9 @@ export class AgenticCommerceFeedDetail { } /** - * Opens a tab of the channel and waits until the provider settings are loaded: before that the - * page validates and saves none of them. + * Opens a tab of the channel and waits until the provider settings are loaded and no loader is + * left: before that the page validates and saves none of them, and Save behaves differently than + * on the page a merchant sees. */ async open(salesChannelId: string, view: 'base' | 'product-comparison' = 'base'): Promise { const providerSettings = [OPEN_AI_CONFIG_DOMAIN, GOOGLE_CONFIG_DOMAIN].map(domain => this.page.waitForResponse( @@ -36,7 +38,7 @@ export class AgenticCommerceFeedDetail { await goToAdminRoute(this.page, `/sw/sales/channel/detail/${salesChannelId}/${view}`); await Promise.all(providerSettings); - await expect(this.page.locator('.sw-skeleton')).toHaveCount(0); + await this.expectLoaded(); } /** 6.5 renders the template select as a native `` value, later lanes as the Meteor select's label. */ + async expectTemplateShown(templateName: 'open_ai' | 'google', label: RegExp): Promise { + const nativeSelect = this.templateSelect.locator('select'); + if (await nativeSelect.count() > 0) { + await expect(nativeSelect).toHaveValue(templateName); + + return; + } + + await expect(this.templateSelect.locator('.mt-select-selection-list__input')).toHaveValue(label); + } + /** The template modal's primary action applies the chosen template. */ async confirmTemplateChange(): Promise { await expect(this.templateModal).toBeVisible(); @@ -60,10 +85,10 @@ export class AgenticCommerceFeedDetail { await expect(this.templateModal).toBeHidden(); } - /** The template modal's secondary action keeps the current template. */ + /** The template modal's secondary action keeps the current template. On 6.5 it is a footer button without a variant. */ async cancelTemplateChange(): Promise { await expect(this.templateModal).toBeVisible(); - await this.templateModal.locator('.mt-button--secondary, .sw-button--secondary').click(); + await this.templateModal.locator('.mt-button--secondary, .sw-button--secondary, .sw-modal__footer .sw-button:not(.sw-button--primary)').click(); await expect(this.templateModal).toBeHidden(); } diff --git a/tests/acceptance/page-objects/UcpSalesChannelDetail.ts b/tests/acceptance/page-objects/UcpSalesChannelDetail.ts index 6b5248b1..57e0c0ac 100644 --- a/tests/acceptance/page-objects/UcpSalesChannelDetail.ts +++ b/tests/acceptance/page-objects/UcpSalesChannelDetail.ts @@ -102,10 +102,15 @@ export class UcpSalesChannelDetail { } private async loadAgenticCommerceTab(salesChannelId: string, navigate: () => Promise): Promise { - const salesChannel = this.page.waitForResponse(response => isRequest(response, 'GET', ucpApiPath(salesChannelId))); - const config = this.page.waitForResponse(response => isRequest(response, 'GET', ucpApiPath(salesChannelId, '/config'))); - const savedPreview = this.page.waitForResponse(response => isRequest(response, 'GET', ucpApiPath(salesChannelId, '/profile-preview'))); - const loadedFormPreview = this.page.waitForResponse(response => isRequest(response, 'POST', ucpApiPath(salesChannelId, '/profile-preview'))); + // Before 6.7.9 the save loads the UCP state once more, so a request of the page being left can + // still answer after the navigation started; its body is gone once the navigation commits. + const navigatedAt = Date.now(); + const loaded = (method: string, path: string) => this.page.waitForResponse(response => isRequest(response, method, path) + && response.request().timing().startTime >= navigatedAt); + const salesChannel = loaded('GET', ucpApiPath(salesChannelId)); + const config = loaded('GET', ucpApiPath(salesChannelId, '/config')); + const savedPreview = loaded('GET', ucpApiPath(salesChannelId, '/profile-preview')); + const loadedFormPreview = loaded('POST', ucpApiPath(salesChannelId, '/profile-preview')); await navigate(); @@ -120,7 +125,8 @@ export class UcpSalesChannelDetail { private async waitUntilSettled(): Promise { await expect(this.agenticCommerceTab).toBeVisible(); await expect(this.agenticCommerceTab.locator('.sw-skeleton')).toHaveCount(0); - await expect(this.page.getByTestId('ac-ucp__expose-switch')).toBeVisible(); + // On 6.6 `mt-switch` passes the hook to its container and its field, so it matches twice. + await expect(this.page.getByTestId('ac-ucp__expose-switch').first()).toBeVisible(); } /** The hook sits on the checkbox's wrapper on every lane. */ diff --git a/tests/acceptance/playwright.config.ts b/tests/acceptance/playwright.config.ts index 0b154086..64680e21 100644 --- a/tests/acceptance/playwright.config.ts +++ b/tests/acceptance/playwright.config.ts @@ -54,7 +54,7 @@ const browser = { const browserWithOwnHosts = { ...browser, launchOptions: { - args: [`--host-resolver-rules=MAP *.ucp-acceptance.test ${new URL(appUrl).hostname}`], + args: signerOnly ? [] : [`--host-resolver-rules=MAP *.ucp-acceptance.test ${new URL(appUrl).hostname}`], }, }; @@ -64,6 +64,8 @@ export default defineConfig({ forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, reporter: 'html', + // Tells the lanes apart in the report CI merges from all of them. + tag: process.env.ACCEPTANCE_LANE ? `@lane-${process.env.ACCEPTANCE_LANE}` : undefined, timeout: 60_000, expect: { timeout: 15_000, diff --git a/tests/acceptance/services/UcpTestDataService.ts b/tests/acceptance/services/UcpTestDataService.ts index 0a6fc1d2..6fd0a88f 100644 --- a/tests/acceptance/services/UcpTestDataService.ts +++ b/tests/acceptance/services/UcpTestDataService.ts @@ -56,7 +56,10 @@ export interface UcpConfigPayload { export const ALL_UCP_CAPABILITIES = ['catalog', 'cart', 'discount', 'checkout', 'order']; -const USER_CREATION_ATTEMPTS = 3; +const USER_CREATION_ATTEMPTS = 5; + +/** MariaDB's "record has changed since last read" (snapshot isolation). */ +const USER_INSERT_CONFLICT_CODE = '1020'; export interface UcpSalesChannel { salesChannel: SalesChannel @@ -475,29 +478,38 @@ export class UcpTestDataService extends TestDataService { } /** - * Core revokes refresh tokens on every user insert, which MariaDB 11.6+ snapshot isolation - * rejects (error 1020) while other workers log in. Before 6.7.13.1 the user row is already - * committed by then, so a failed attempt is registered for cleanup when the user exists. + * Core revokes a user's refresh tokens when it inserts the user. The delete scans the whole + * `refresh_token` table (no index on `user_id`), and MariaDB 11.6+ snapshot isolation rejects it + * with error 1020 while other workers log in; core answers 500 and does not retry. Only that + * conflict is retried, with a fresh user and a growing, jittered pause, since immediate retries + * were seen to fail again. Since 6.7.13.1 the insert rolls back with the revocation; before, the + * user row is already committed, so a failed attempt whose user exists is used and registered + * for cleanup. */ async createUserRetryingTokenConflicts(overrides: Partial = {}): Promise { - const password = overrides.password ?? 'shopware'; + const { localeId } = await this.getLanguageById(this.defaultSalesChannel.languageId); for (let attempt = 1; ; attempt++) { - const { uuid: id } = this.IdProvider.getIdPair(); - try { - return await this.createUser({ ...overrides, id, password }); + const user = this.getBasicUserStruct(localeId, overrides) as User; + const response = await this.AdminApiClient.post('user', { data: user }); + if (response.ok()) { + this.addCreatedRecord('user', user.id); + + return { ...await this.getUserById(user.id), password: user.password }; } - catch (error) { - const userLookup = await this.AdminApiClient.get(`./user/${id}`); - if (userLookup.ok()) { - this.addCreatedRecord('user', id); + const existing = await this.AdminApiClient.get(`./user/${user.id}`); + if (existing.ok()) { + this.addCreatedRecord('user', user.id); - return { ...((await userLookup.json()) as { data: User }).data, password }; - } - if (attempt === USER_CREATION_ATTEMPTS) { - throw error; - } + return { ...((await existing.json()) as { data: User }).data, password: user.password }; + } + + const code = response.status() >= 500 ? await UcpTestDataService.refusalCode(response).catch(() => null) : null; + if (attempt === USER_CREATION_ATTEMPTS || code !== USER_INSERT_CONFLICT_CODE) { + throw new Error(`POST /api/user failed (attempt ${attempt}): HTTP ${response.status()} ${response.statusText()} ${(await response.text()).slice(0, 2000)}`); } + const pause = 250 * 2 ** (attempt - 1); + await new Promise(resolve => setTimeout(resolve, pause + Math.random() * pause)); } } diff --git a/tests/acceptance/tests/KnownBlocked/product-feed-export.spec.ts b/tests/acceptance/tests/KnownBlocked/product-feed-export.spec.ts new file mode 100644 index 00000000..1a562010 --- /dev/null +++ b/tests/acceptance/tests/KnownBlocked/product-feed-export.spec.ts @@ -0,0 +1,30 @@ +import { satisfies } from 'compare-versions'; +import { test } from '@fixtures/AcceptanceTest'; +import { AgenticCommerceFeedDetail } from '@page-objects/AgenticCommerceFeedDetail'; +import { expectCancelledTemplateChangeShowsCurrent, expectNewFeedStaysUnsaved, expectSaveRefusedWithoutReturnPolicyUrl } from '../UcpAdmin/feedSaveSteps'; +import { NEW_FEED_SAVES_ON_SECOND_CLICK, SAVE_SHOWS_NO_REQUIRED_SETTING_ERROR, TEMPLATE_SELECT_KEEPS_CANCELLED_CHOICE } from '../UcpContent/knownBlockerRanges'; + +test.describe('Saving a feed channel with an empty required setting before 6.6.0.0 @UcpKnownBlocked @UcpBlockedByD12', () => { + test('an OpenAI feed is not saved without a return policy URL, and the first Save marks the field', async ({ InstanceMeta, TestDataService, AdminPage }) => { + test.skip(!satisfies(InstanceMeta.version, SAVE_SHOWS_NO_REQUIRED_SETTING_ERROR), 'D12 applies to a saved channel only below 6.6.0.0; tests/UcpAdmin/product-feed-export.spec.ts covers newer versions'); + const { salesChannel } = await TestDataService.createFeedSalesChannelWithExport('open-ai'); + const feed = new AgenticCommerceFeedDetail(AdminPage); + await feed.open(salesChannel.id); + + await expectSaveRefusedWithoutReturnPolicyUrl(feed, TestDataService, salesChannel.id); + }); +}); + +test.describe('Saving a new feed channel before 6.7.0.0 @UcpKnownBlocked @UcpBlockedByD12', () => { + test('a new feed channel without a return policy URL stays unsaved however often Save is clicked', async ({ InstanceMeta, AdminPage }) => { + test.skip(!satisfies(InstanceMeta.version, NEW_FEED_SAVES_ON_SECOND_CLICK), 'D12 applies to a new channel only below 6.7.0.0; tests/UcpAdmin/product-feed-export.spec.ts covers newer versions'); + await expectNewFeedStaysUnsaved(new AgenticCommerceFeedDetail(AdminPage)); + }); +}); + +test.describe('Template select after a cancelled change @UcpKnownBlocked @UcpBlockedByD13', () => { + test('cancelling the template change shows the current template again before the Meteor select', async ({ InstanceMeta, TestDataService, AdminPage }) => { + test.skip(!satisfies(InstanceMeta.version, TEMPLATE_SELECT_KEEPS_CANCELLED_CHOICE), 'D13 applies only below 6.7.0.0; tests/UcpAdmin/product-feed-export.spec.ts covers newer versions'); + await expectCancelledTemplateChangeShowsCurrent(new AgenticCommerceFeedDetail(AdminPage), TestDataService); + }); +}); diff --git a/tests/acceptance/tests/UcpAdmin/export-channel-attribution.spec.ts b/tests/acceptance/tests/UcpAdmin/export-channel-attribution.spec.ts index e9758393..27bc0851 100644 --- a/tests/acceptance/tests/UcpAdmin/export-channel-attribution.spec.ts +++ b/tests/acceptance/tests/UcpAdmin/export-channel-attribution.spec.ts @@ -1,22 +1,40 @@ +import { satisfies } from 'compare-versions'; import { expect, test } from '@fixtures/AcceptanceTest'; import type { Page, Response } from '@playwright/test'; import { goToAdminRoute } from '@page-objects/adminRoute'; import { randomUUID } from 'node:crypto'; -/** A list search filtering on the feed channel; other searches of the list may run meanwhile. */ -function isFilteredSearch(response: Response, entity: 'order' | 'customer', feedId: string): boolean { +/** A search of the list whose criteria carry every one of `needles`; other searches, such as the dashboard's, may run meanwhile. */ +function isListSearch(response: Response, entity: 'order' | 'customer', ...needles: string[]): boolean { const criteria = response.request().postData() ?? ''; return response.request().method() === 'POST' && new URL(response.url()).pathname.endsWith(`/api/search/${entity}`) - && criteria.includes('salesChannelTracking.salesChannelId') - && criteria.includes(feedId); + && needles.every(needle => criteria.includes(needle)); } -/** The write that stores the user's list filters. */ +const isFilteredSearch = (response: Response, entity: 'order' | 'customer', feedId: string) => isListSearch(response, entity, 'salesChannelTracking.salesChannelId', feedId); + +/** + * Opens the list searched for `term` and waits for that search to answer. A busy server can take + * longer than an assertion's timeout to answer it, and only then can the rows render. + */ +async function openSearchedList(page: Page, entity: 'order' | 'customer', term: string): Promise { + const listRoute = `/sw/${entity}/index?term=${term}`; + const termSearch = page.waitForResponse(response => isListSearch(response, entity, term)); + await goToAdminRoute(page, listRoute); + const response = await termSearch; + expect(response.ok(), await response.text()).toBeTruthy(); + + return listRoute; +} + +/** The write that stores the user's list filters: `config-me`, or on 6.5 the `user_config` entity itself. */ function isStoredFilter(response: Response, feedId: string): boolean { - return response.request().method() === 'PATCH' - && new URL(response.url()).pathname.endsWith('/api/_info/config-me') + const pathname = new URL(response.url()).pathname; + + return ['PATCH', 'POST'].includes(response.request().method()) + && (pathname.endsWith('/api/_info/config-me') || /\/api\/user-config(\/[0-9a-f]{32})?$/.test(pathname)) && (response.request().postData() ?? '').includes(feedId); } @@ -46,7 +64,8 @@ async function filterByExportChannel(page: Page, entity: 'order' | 'customer', f const filteredList = await page.context().newPage(); const storedFilterSearch = filteredList.waitForResponse(response => isFilteredSearch(response, entity, feed.id)); await goToAdminRoute(filteredList, listRoute); - await storedFilterSearch; + const response = await storedFilterSearch; + expect(response.ok(), await response.text()).toBeTruthy(); return filteredList; } @@ -75,8 +94,7 @@ test.describe('Orders and customers attributed to a feed channel @UcpAdmin', () await TestDataService.attributeOrderToFeedChannel(viaOtherFeed.id, otherFeed.id); const page = await UcpAclUsers.asFreshAdmin(); - const listRoute = `/sw/order/index?term=${buyer.lastName}`; - await goToAdminRoute(page, listRoute); + const listRoute = await openSearchedList(page, 'order', buyer.lastName); for (const order of [attributed, direct, viaOtherFeed]) { await expect(rowOf(page, order.orderNumber)).toBeVisible(); } @@ -99,8 +117,7 @@ test.describe('Orders and customers attributed to a feed channel @UcpAdmin', () await TestDataService.attributeCustomerToFeedChannel(referredElsewhere.id, otherFeed.id); const page = await UcpAclUsers.asFreshAdmin(); - const listRoute = `/sw/customer/index?term=${lastName}`; - await goToAdminRoute(page, listRoute); + const listRoute = await openSearchedList(page, 'customer', lastName); for (const customer of [referred, direct, referredElsewhere]) { await expect(rowOf(page, customer.email)).toBeVisible(); } @@ -112,7 +129,8 @@ test.describe('Orders and customers attributed to a feed channel @UcpAdmin', () await expect(rowOf(filteredList, referredElsewhere.email)).toHaveCount(0); }); - test('the feed channel\'s insights count only the orders and customers attributed to it', async ({ TestDataService, AdminPage }) => { + test('the feed channel\'s insights count only the orders and customers attributed to it', async ({ InstanceMeta, TestDataService, UcpAclUsers }) => { + test.skip(satisfies(InstanceMeta.version, '<6.7.10.0'), 'the totals are shown from 6.7.10.0, with core\'s product export insights; the plugin\'s own statistics tab before that shows the charts only'); const { salesChannel: feed } = await TestDataService.createFeedSalesChannelWithExport(); const { salesChannel: otherFeed } = await TestDataService.createFeedSalesChannelWithExport(); const product = await TestDataService.createBasicProduct(); @@ -130,12 +148,13 @@ test.describe('Orders and customers attributed to a feed channel @UcpAdmin', () await TestDataService.attributeOrderToFeedChannel(otherFeedOrder.id, otherFeed.id); await TestDataService.attributeCustomerToFeedChannel(referredElsewhere.id, otherFeed.id); - await goToAdminRoute(AdminPage, `/sw/sales/channel/detail/${feed.id}/base`); + const page = await UcpAclUsers.asFreshAdmin(); + await goToAdminRoute(page, `/sw/sales/channel/detail/${feed.id}/base`); // From 6.7.10 core serves the insights under its own route and markup. - await AdminPage.locator(`a[href$="/${feed.id}/product-export-insights"], a[href$="/${feed.id}/agentic-commerce-statistics"]`).click(); + await page.locator(`a[href$="/${feed.id}/product-export-insights"], a[href$="/${feed.id}/agentic-commerce-statistics"]`).click(); - const total = (card: 'orders' | 'customers') => AdminPage.getByTestId(`ac-insights__${card}-total`) - .or(AdminPage.locator(`.sw-sales-channel-detail-product-export-insights__${card} .sw-sales-channel-detail-product-export-insights__summary`)); + const total = (card: 'orders' | 'customers') => page.getByTestId(`ac-insights__${card}-total`) + .or(page.locator(`.sw-sales-channel-detail-product-export-insights__${card} .sw-sales-channel-detail-product-export-insights__summary`)); await expect(total('orders')).toHaveText(/\b2$/); await expect(total('customers')).toHaveText(/\b2$/); }); diff --git a/tests/acceptance/tests/UcpAdmin/feedSaveSteps.ts b/tests/acceptance/tests/UcpAdmin/feedSaveSteps.ts new file mode 100644 index 00000000..0aa52da9 --- /dev/null +++ b/tests/acceptance/tests/UcpAdmin/feedSaveSteps.ts @@ -0,0 +1,55 @@ +import { expect } from '@playwright/test'; +import type { Request } from '@playwright/test'; +import type { AgenticCommerceFeedDetail } from '@page-objects/AgenticCommerceFeedDetail'; +import type { UcpTestDataService } from '@services/UcpTestDataService'; + +export const RETURN_POLICY_URL = 'SwagAgenticCommerce.openAiProductExport.returnPolicyUrl'; + +/** A write the detail page's Save could send: REST on older lanes, sync on current ones, then the provider settings. */ +const SAVE_WRITE_PATHS = ['/api/sales-channel', '/api/product-export', '/api/_action/sync', '/api/_action/system-config/batch']; +export const isSaveWrite = (request: Request) => request.method() !== 'GET' + && SAVE_WRITE_PATHS.some(path => new URL(request.url()).pathname.includes(path)); + +const fieldError = (feed: AgenticCommerceFeedDetail) => feed.fieldOf(feed.returnPolicyUrl).locator('.mt-field__error, .sw-field__error'); + +/** Saves an opened OpenAI feed whose return policy URL is empty: the field is marked, and nothing is written. */ +export async function expectSaveRefusedWithoutReturnPolicyUrl(feed: AgenticCommerceFeedDetail, testData: UcpTestDataService, salesChannelId: string): Promise { + const writes: Request[] = []; + const recordWrite = (request: Request) => isSaveWrite(request) && writes.push(request); + feed.page.on('request', recordWrite); + + await expect(feed.returnPolicyUrl).toHaveValue(''); + await feed.saveButton.click(); + + await expect(fieldError(feed)).toBeVisible(); + feed.page.off('request', recordWrite); + expect(writes.map(request => request.url()), 'the refused save wrote nothing').toEqual([]); + expect(await testData.getChannelSystemConfig('SwagAgenticCommerce.openAiProductExport', salesChannelId)) + .not.toHaveProperty([RETURN_POLICY_URL]); +} + +/** Saves a new feed channel without a return policy URL twice: the field stays marked, and nothing is written. */ +export async function expectNewFeedStaysUnsaved(feed: AgenticCommerceFeedDetail): Promise { + await feed.openNew(); + + const writes: Request[] = []; + feed.page.on('request', request => isSaveWrite(request) && writes.push(request)); + + for (const click of [1, 2]) { + await feed.saveButton.click(); + + await expect(fieldError(feed), `marked after click ${click}`).toBeVisible(); + expect(writes.map(request => request.url()), `nothing sent after click ${click}`).toEqual([]); + } +} + +/** Cancels a change from the OpenAI to the Google template: the select shows OpenAI again. */ +export async function expectCancelledTemplateChangeShowsCurrent(feed: AgenticCommerceFeedDetail, testData: UcpTestDataService): Promise { + const { salesChannel } = await testData.createFeedSalesChannelWithExport('open-ai'); + await feed.open(salesChannel.id); + + await feed.chooseTemplate('google'); + await feed.cancelTemplateChange(); + + await feed.expectTemplateShown('open_ai', /OpenAI/); +} diff --git a/tests/acceptance/tests/UcpAdmin/product-feed-export.spec.ts b/tests/acceptance/tests/UcpAdmin/product-feed-export.spec.ts index 5acba709..f1e3dc98 100644 --- a/tests/acceptance/tests/UcpAdmin/product-feed-export.spec.ts +++ b/tests/acceptance/tests/UcpAdmin/product-feed-export.spec.ts @@ -1,14 +1,9 @@ +import { satisfies } from 'compare-versions'; import { expect, test } from '@fixtures/AcceptanceTest'; -import type { Request } from '@playwright/test'; import { AgenticCommerceFeedDetail } from '@page-objects/AgenticCommerceFeedDetail'; import { readFeedTemplates } from '@services/pluginSource'; - -const RETURN_POLICY_URL = 'SwagAgenticCommerce.openAiProductExport.returnPolicyUrl'; - -/** A write the detail page's Save could send: REST on older lanes, sync on current ones, then the provider settings. */ -const SAVE_WRITE_PATHS = ['/api/sales-channel', '/api/product-export', '/api/_action/sync', '/api/_action/system-config/batch']; -const isSaveWrite = (request: Request) => request.method() !== 'GET' - && SAVE_WRITE_PATHS.some(path => new URL(request.url()).pathname.includes(path)); +import { NEW_FEED_SAVES_ON_SECOND_CLICK, SAVE_SHOWS_NO_REQUIRED_SETTING_ERROR, TEMPLATE_SELECT_KEEPS_CANCELLED_CHOICE } from '../UcpContent/knownBlockerRanges'; +import { RETURN_POLICY_URL, expectCancelledTemplateChangeShowsCurrent, expectNewFeedStaysUnsaved, expectSaveRefusedWithoutReturnPolicyUrl } from './feedSaveSteps'; test.describe('Product feed export settings @UcpAdmin', () => { test('switching the export template asks first, then stores the template and aligns the file', async ({ TestDataService, AdminPage }) => { @@ -51,6 +46,11 @@ test.describe('Product feed export settings @UcpAdmin', () => { }); }); + test('cancelling the template change shows the current template again', async ({ InstanceMeta, TestDataService, AdminPage }) => { + test.skip(satisfies(InstanceMeta.version, TEMPLATE_SELECT_KEEPS_CANCELLED_CHOICE), 'known blocker D13, asserted in tests/KnownBlocked/product-feed-export.spec.ts'); + await expectCancelledTemplateChangeShowsCurrent(new AgenticCommerceFeedDetail(AdminPage), TestDataService); + }); + test('resetting the export template restores the provider\'s default', async ({ TestDataService, AdminPage }) => { const { salesChannel } = await TestDataService.createFeedSalesChannelWithExport('open-ai', { bodyTemplate: '{{ product.productNumber }}' }); await TestDataService.setChannelSystemConfig(salesChannel.id, { [RETURN_POLICY_URL]: 'https://shop.example/returns' }); @@ -65,24 +65,14 @@ test.describe('Product feed export settings @UcpAdmin', () => { expect(stored.bodyTemplate).toBe(openAi.bodyTemplate); }); - test('an OpenAI feed is not saved without a return policy URL', async ({ TestDataService, AdminPage }) => { + test('an OpenAI feed is not saved without a return policy URL', async ({ InstanceMeta, TestDataService, AdminPage }) => { + test.skip(satisfies(InstanceMeta.version, SAVE_SHOWS_NO_REQUIRED_SETTING_ERROR), 'known blocker D12, asserted in tests/KnownBlocked/product-feed-export.spec.ts'); const { salesChannel } = await TestDataService.createFeedSalesChannelWithExport('open-ai'); const feed = new AgenticCommerceFeedDetail(AdminPage); await feed.open(salesChannel.id); - const requests: Request[] = []; - const recordRequest = (request: Request) => requests.push(request); - AdminPage.on('request', recordRequest); - await test.step('saving without it marks the field and sends nothing', async () => { - await expect(feed.returnPolicyUrl).toHaveValue(''); - await feed.saveButton.click(); - - await expect(feed.fieldOf(feed.returnPolicyUrl).locator('.mt-field__error, .sw-field__error')).toBeVisible(); - AdminPage.off('request', recordRequest); - expect(requests.filter(isSaveWrite).map(request => request.url()), 'the refused save wrote nothing').toEqual([]); - expect(await TestDataService.getChannelSystemConfig('SwagAgenticCommerce.openAiProductExport', salesChannel.id)) - .not.toHaveProperty([RETURN_POLICY_URL]); + await expectSaveRefusedWithoutReturnPolicyUrl(feed, TestDataService, salesChannel.id); }); await test.step('with it, the save goes through and stores the URL', async () => { @@ -95,6 +85,11 @@ test.describe('Product feed export settings @UcpAdmin', () => { }); }); + test('a new feed channel without a return policy URL stays unsaved however often Save is clicked', async ({ InstanceMeta, AdminPage }) => { + test.skip(satisfies(InstanceMeta.version, NEW_FEED_SAVES_ON_SECOND_CLICK), 'known blocker D12, asserted in tests/KnownBlocked/product-feed-export.spec.ts'); + await expectNewFeedStaysUnsaved(new AgenticCommerceFeedDetail(AdminPage)); + }); + test('tracking codes entered for a feed are stored in the channel configuration', async ({ TestDataService, AdminPage }) => { const { salesChannel } = await TestDataService.createFeedSalesChannelWithExport('open-ai'); await TestDataService.setChannelSystemConfig(salesChannel.id, { [RETURN_POLICY_URL]: 'https://shop.example/returns' }); diff --git a/tests/acceptance/tests/UcpContent/knownBlockerRanges.ts b/tests/acceptance/tests/UcpContent/knownBlockerRanges.ts index 2a5bc2ac..db9a4f7c 100644 --- a/tests/acceptance/tests/UcpContent/knownBlockerRanges.ts +++ b/tests/acceptance/tests/UcpContent/knownBlockerRanges.ts @@ -1,3 +1,6 @@ export const FALLBACK_DROPS_FILES_WITHOUT_UCP = '<6.7.12.0'; export const CORE_SERVES_NO_UPPERCASE_AGENTS_MD = '>=6.7.12.0 <6.7.13.0'; export const VALIDATE_SKIPS_PROVIDER_VALIDATORS = '<6.7.10.0'; +export const SAVE_SHOWS_NO_REQUIRED_SETTING_ERROR = '<6.6.0.0'; +export const NEW_FEED_SAVES_ON_SECOND_CLICK = '<6.7.0.0'; +export const TEMPLATE_SELECT_KEEPS_CANCELLED_CHOICE = '<6.7.0.0';